Skip to content

Commit 9e83a1f

Browse files
committed
add - Enable to use cloud logging with fastapi
1 parent 312f1b3 commit 9e83a1f

10 files changed

+1372
-0
lines changed

fastapi_cloud_logging/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .fastapi_cloud_logging_handler import FastAPILoggingHandler
2+
from .request_logging_middleware import RequestLoggingMiddleware
3+
4+
__all__ = ["FastAPILoggingHandler", "RequestLoggingMiddleware"]
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import json
2+
3+
from google.cloud.logging_v2.handlers import CloudLoggingFilter, CloudLoggingHandler
4+
from google.cloud.logging_v2.handlers._helpers import _parse_xcloud_trace
5+
from google.cloud.logging_v2.handlers.handlers import DEFAULT_LOGGER_NAME
6+
from google.cloud.logging_v2.handlers.transports import BackgroundThreadTransport
7+
8+
from .request_logging_middleware import _FASTAPI_REQUEST_CONTEXT
9+
10+
11+
class FastAPILoggingFilter(CloudLoggingFilter):
12+
"""
13+
This LoggingFilter is extended for logging a request on FastAPI.
14+
This data can be manually overwritten using the `extras` argument when writing logs.
15+
"""
16+
17+
def filter(self, record):
18+
"""
19+
Add new Cloud Logging data to each LogRecord as it comes in
20+
"""
21+
user_labels = getattr(record, "labels", {})
22+
# infer request data from context_vars
23+
(
24+
inferred_http,
25+
inferred_trace,
26+
inferred_span,
27+
inferred_sampled,
28+
) = self.get_request_data()
29+
if inferred_trace is not None and self.project is not None:
30+
# add full path for detected trace
31+
inferred_trace = f"projects/{self.project}/traces/{inferred_trace}"
32+
# set new record values
33+
record._resource = getattr(record, "resource", None)
34+
record._trace = getattr(record, "trace", inferred_trace) or None
35+
record._span_id = getattr(record, "span_id", inferred_span) or None
36+
record._trace_sampled = bool(getattr(record, "trace_sampled", inferred_sampled))
37+
record._http_request = getattr(record, "http_request", inferred_http)
38+
record._source_location = CloudLoggingFilter._infer_source_location(record)
39+
# add logger name as a label if possible
40+
logger_label = {"python_logger": record.name} if record.name else {}
41+
record._labels = {**logger_label, **self.default_labels, **user_labels} or None
42+
# create string representations for structured logging
43+
record._trace_str = record._trace or ""
44+
record._span_id_str = record._span_id or ""
45+
record._trace_sampled_str = "true" if record._trace_sampled else "false"
46+
record._http_request_str = json.dumps(
47+
record._http_request or {}, ensure_ascii=False
48+
)
49+
record._source_location_str = json.dumps(
50+
record._source_location or {}, ensure_ascii=False
51+
)
52+
record._labels_str = json.dumps(record._labels or {}, ensure_ascii=False)
53+
return True
54+
55+
def get_request_data(self):
56+
request = _FASTAPI_REQUEST_CONTEXT.get()
57+
if request is None:
58+
return None, None, None, False
59+
60+
# build up http request data
61+
http_request = {
62+
"requestMethod": request.request_method,
63+
"requestUrl": request.request_url,
64+
"requestSize": request.content_length,
65+
"userAgent": request.user_agent,
66+
"remoteIp": request.remote_ip,
67+
"referer": request.referer,
68+
"protocol": request.protocol,
69+
}
70+
71+
return http_request, *_parse_xcloud_trace(request.cloud_trace_content)
72+
73+
74+
class FastAPILoggingHandler(CloudLoggingHandler):
75+
"""
76+
This LoggingHandler is extended for logging a request on FastAPI.
77+
Usage of this LoggingHandler is the same as CloudLoggingHandler.
78+
"""
79+
80+
def __init__(
81+
self,
82+
client,
83+
*,
84+
name=DEFAULT_LOGGER_NAME,
85+
transport=BackgroundThreadTransport,
86+
resource=None,
87+
labels=None,
88+
stream=None,
89+
):
90+
"""
91+
Args:
92+
client (~logging_v2.client.Client):
93+
The authenticated Google Cloud Logging client for this
94+
handler to use.
95+
name (str): the name of the custom log in Cloud Logging.
96+
Defaults to 'python'. The name of the Python logger will be represented
97+
in the ``python_logger`` field.
98+
transport (~logging_v2.transports.Transport):
99+
Class for creating new transport objects. It should
100+
extend from the base :class:`.Transport` type and
101+
implement :meth`.Transport.send`. Defaults to
102+
:class:`.BackgroundThreadTransport`. The other
103+
option is :class:`.SyncTransport`.
104+
resource (~logging_v2.resource.Resource):
105+
Resource for this Handler. If not given, will be inferred from the environment.
106+
labels (Optional[dict]): Additional labels to attach to logs.
107+
stream (Optional[IO]): Stream to be used by the handler.
108+
"""
109+
super(FastAPILoggingHandler, self).__init__(
110+
client,
111+
name=name,
112+
transport=transport,
113+
resource=resource,
114+
labels=labels,
115+
stream=stream,
116+
)
117+
118+
# replace default cloud logging filter
119+
for default_filter in self.filters:
120+
if default_filter.isinstance(CloudLoggingFilter):
121+
self.removeFilter(default_filter)
122+
123+
log_filter = FastAPILoggingFilter(
124+
project=self.project_id, default_labels=labels
125+
)
126+
self.addFilter(log_filter)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from contextvars import ContextVar
2+
from dataclasses import dataclass
3+
from typing import Optional
4+
5+
from fastapi import Request
6+
from starlette.middleware.base import BaseHTTPMiddleware
7+
8+
9+
@dataclass
10+
class FastAPIRequestContext:
11+
request_method: str
12+
"""HTTP Method Name"""
13+
request_url: str
14+
"""HTTP Request URI"""
15+
content_length: Optional[int]
16+
"""Size of the message body"""
17+
user_agent: str
18+
"""User Agent"""
19+
remote_ip: Optional[str]
20+
"""Remote IP Address"""
21+
referer: Optional[str]
22+
"""HTTP Referer"""
23+
protocol: str
24+
"""HTTP Protocol Scheme"""
25+
cloud_trace_content: Optional[str]
26+
"""Cloud Trace Header"""
27+
28+
29+
_FASTAPI_REQUEST_CONTEXT: ContextVar[Optional[FastAPIRequestContext]] = ContextVar(
30+
"fastapi_request_context", default=None
31+
)
32+
_HTTP_CONTENT_LENGTH = "content-length"
33+
_HTTP_USER_AGENT = "user-agent"
34+
_HTTP_FORWARDED_FOR_HEADER = "x-forwarded-for"
35+
_HTTP_REFERER_HEADER = "referer"
36+
_HTTP_TRACE_HEADER = "x-cloud-trace_context"
37+
38+
39+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
40+
async def dispatch(self, request: Request, call_next):
41+
self.set_request_context(request=request)
42+
return await call_next(request)
43+
44+
def set_request_context(self, request: Request) -> None:
45+
_FASTAPI_REQUEST_CONTEXT.set(self._parse_request(request))
46+
47+
def _parse_request(self, request: Request) -> FastAPIRequestContext:
48+
return FastAPIRequestContext(
49+
request_method=request.method,
50+
request_url=str(request.url),
51+
content_length=self._parse_content_length(
52+
request.headers.get(_HTTP_CONTENT_LENGTH)
53+
),
54+
user_agent=request.headers.get(_HTTP_USER_AGENT),
55+
remote_ip=request.headers.get(
56+
_HTTP_FORWARDED_FOR_HEADER, request.client.host
57+
),
58+
referer=request.headers.get(_HTTP_REFERER_HEADER),
59+
protocol=request.url.scheme,
60+
cloud_trace_content=request.headers.get(_HTTP_TRACE_HEADER),
61+
)
62+
63+
def _parse_content_length(self, content_header: Optional[str]) -> Optional[int]:
64+
if content_header is None:
65+
return None
66+
content_length = None
67+
try:
68+
content_length = int(content_header)
69+
except (ValueError, TypeError):
70+
content_length = None
71+
return content_length

0 commit comments

Comments
 (0)