Skip to content

Commit 7315cec

Browse files
authored
Merge pull request #65 from chr1st1ank/fastapi-support
Fastapi backend
2 parents bddffb2 + 0fd9df0 commit 7315cec

File tree

21 files changed

+681
-10
lines changed

21 files changed

+681
-10
lines changed

.github/workflows/code_quality.yml

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ jobs:
3535
runs-on: ubuntu-latest
3636

3737
strategy:
38-
max-parallel: 4
3938
matrix:
4039
python-version: ["3.7", "3.8", "3.9"]
4140

@@ -52,4 +51,28 @@ jobs:
5251
pip install -r test-requirements.txt
5352
- name: Run tests
5453
run: |
55-
pytest
54+
pytest --ignore tests/smoketests
55+
56+
backendSmoketests:
57+
name: Individual Backend Smoketests
58+
runs-on: ubuntu-latest
59+
60+
strategy:
61+
matrix:
62+
backend: ["fastapi", "flask", "quart", "sanic"]
63+
64+
steps:
65+
- name: Set up Python ${{ matrix.python-version }}
66+
uses: actions/setup-python@v2
67+
with:
68+
python-version: 3.9
69+
- name: "Git checkout"
70+
uses: actions/checkout@v2
71+
- name: Install dependencies
72+
run: |
73+
python -m pip install --upgrade pip
74+
pip install -r tests/smoketests/${{ matrix.backend }}/requirements.txt
75+
- name: Run tests
76+
run: |
77+
export backend=${{ matrix.backend }}
78+
pytest tests/smoketests/test_run_smoketest.py

README.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ If you're using Cloud Foundry, it might worth to check out the library [SAP/cf-p
2323
2. Lightweight, no dependencies, minimal configuration needed (1 LoC to get it working)
2424
3. Seamlessly integrate with Python native **logging** module. Support both Python 2.7.x and 3.x
2525
4. Auto extract **correlation-id** for distributed tracing [\[1\]](#1-what-is-correlation-idrequest-id)
26-
5. Support HTTP request instrumentation. Built in support for [Flask](https://github.com/pallets/flask/), [Sanic](https://github.com/channelcat/sanic), [Quart](https://gitlab.com/pgjones/quart), [Connexion](https://github.com/zalando/connexion). Extensible to support other web frameworks. PR welcome :smiley: .
26+
5. Support HTTP request instrumentation. Built in support for [FastAPI](https://fastapi.tiangolo.com/), [Flask](https://github.com/pallets/flask/), [Sanic](https://github.com/channelcat/sanic), [Quart](https://gitlab.com/pgjones/quart), [Connexion](https://github.com/zalando/connexion). Extensible to support other web frameworks. PR welcome :smiley: .
2727
6. Highly customizable: support inject arbitrary extra properties to JSON log message, override logging formatter, etc.
2828
7. Production ready, has been used in production since 2017
2929

@@ -52,8 +52,31 @@ logger.info("test logging statement")
5252
```
5353

5454
## 2.2 Web application log
55-
### Flask
55+
### FastAPI
56+
```python
57+
import datetime, logging, sys, json_logging, fastapi, uvicorn
58+
59+
app = fastapi.FastAPI()
60+
json_logging.init_fastapi(enable_json=True)
61+
json_logging.init_request_instrument(app)
62+
63+
# init the logger as usual
64+
logger = logging.getLogger("test-logger")
65+
logger.setLevel(logging.DEBUG)
66+
logger.addHandler(logging.StreamHandler(sys.stdout))
5667

68+
@app.get('/')
69+
def home():
70+
logger.info("test log statement")
71+
logger.info("test log statement with extra props", extra={'props': {"extra_property": 'extra_value'}})
72+
correlation_id = json_logging.get_correlation_id()
73+
return "Hello world : " + str(datetime.datetime.now())
74+
75+
if __name__ == "__main__":
76+
uvicorn.run(app, host='0.0.0.0', port=5000)
77+
```
78+
79+
### Flask
5780
```python
5881
import datetime, logging, sys, json_logging, flask
5982

@@ -102,7 +125,7 @@ async def home(request):
102125
return sanic.response.text("hello world")
103126

104127
if __name__ == "__main__":
105-
app.run(host="0.0.0.0", port=8000)
128+
app.run(host="0.0.0.0", port=5000)
106129
```
107130

108131
### Quart
@@ -227,7 +250,7 @@ ResponseAdapter | Helper class help to extract logging-relevant information from
227250
FrameworkConfigurator | Class to perform logging configuration for given framework as needed | no
228251
AppRequestInstrumentationConfigurator | Class to perform request instrumentation logging configuration | no
229252

230-
Take a look at [**json_logging/base_framework.py**](blob/master/json_logging/framework_base.py), [**json_logging.flask**](tree/master/json_logging/framework/flask) and [**json_logging.sanic**](/tree/master/json_logging/framework/sanic) packages for reference implementations.
253+
Take a look at [**json_logging/base_framework.py**](json_logging/framework_base.py), [**json_logging.flask**](json_logging/framework/flask) and [**json_logging.sanic**](json_logging/framework/sanic) packages for reference implementations.
231254

232255
# 6. FAQ & Troubleshooting
233256
1. I configured everything, but no logs are printed out?

example/fastapi_sample_app.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import logging
2+
3+
import fastapi
4+
5+
import json_logging
6+
7+
app = fastapi.FastAPI()
8+
9+
# init the logger as usual
10+
logger = logging.getLogger(__name__)
11+
logger.setLevel(logging.DEBUG)
12+
13+
@app.get('/')
14+
async def home():
15+
logger.info("test log statement")
16+
logger.info("test log statement with extra props", extra={'props': {"extra_property": 'extra_value'}})
17+
correlation_id = json_logging.get_correlation_id()
18+
return "hello world" \
19+
"\ncorrelation_id : " + correlation_id
20+
21+
22+
@app.get('/exception')
23+
def exception():
24+
try:
25+
raise RuntimeError
26+
except BaseException as e:
27+
logger.error("Error occurred", exc_info=e)
28+
logger.exception("Error occurred", exc_info=e)
29+
return "Error occurred, check log for detail"
30+
31+
32+
@app.get('/exclude_from_request_instrumentation')
33+
def exclude_from_request_instrumentation():
34+
return "this request wont log request instrumentation information"
35+
36+
37+
if __name__ == "__main__":
38+
import uvicorn
39+
logging_config = {
40+
'version': 1,
41+
'disable_existing_loggers': False,
42+
'handlers': {
43+
'default_handler': {
44+
'class': 'logging.StreamHandler',
45+
'level': 'DEBUG',
46+
},
47+
},
48+
'loggers': {
49+
'': {
50+
'handlers': ['default_handler'],
51+
}
52+
}
53+
}
54+
json_logging.init_fastapi(enable_json=True)
55+
json_logging.init_request_instrument(app, exclude_url_patterns=[r'^/exclude_from_request_instrumentation'])
56+
uvicorn.run(app, host='0.0.0.0', port=5000, log_level="debug", log_config=logging_config)

json_logging/__init__.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,16 @@ def init_quart(custom_formatter=None, enable_json=False):
395395

396396
def init_connexion(custom_formatter=None, enable_json=False):
397397
__init(framework_name='connexion', custom_formatter=custom_formatter, enable_json=enable_json)
398+
399+
400+
# register FastAPI support
401+
import json_logging.framework.fastapi as fastapi_support
402+
403+
if fastapi_support.is_fastapi_present():
404+
register_framework_support('fastapi', app_configurator=None,
405+
app_request_instrumentation_configurator=fastapi_support.FastAPIAppRequestInstrumentationConfigurator,
406+
request_adapter_class=fastapi_support.FastAPIRequestAdapter,
407+
response_adapter_class=fastapi_support.FastAPIResponseAdapter)
408+
409+
def init_fastapi(custom_formatter=None, enable_json=False):
410+
__init(framework_name='fastapi', custom_formatter=custom_formatter, enable_json=enable_json)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
def is_fastapi_present():
3+
# noinspection PyPep8,PyBroadException
4+
try:
5+
import fastapi
6+
import starlette
7+
return True
8+
except:
9+
return False
10+
11+
12+
if is_fastapi_present():
13+
from .implementation import FastAPIAppRequestInstrumentationConfigurator, FastAPIRequestAdapter, FastAPIResponseAdapter
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import logging
2+
3+
import json_logging
4+
import json_logging.framework
5+
from json_logging.framework_base import AppRequestInstrumentationConfigurator, RequestAdapter, ResponseAdapter
6+
7+
from json_logging.util import is_not_match_any_pattern
8+
9+
import fastapi
10+
import starlette.requests
11+
import starlette.responses
12+
13+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
14+
from starlette.requests import Request
15+
from starlette.responses import Response
16+
from starlette.types import ASGIApp
17+
18+
19+
class JSONLoggingASGIMiddleware(BaseHTTPMiddleware):
20+
def __init__(self, app: ASGIApp, exclude_url_patterns=tuple()) -> None:
21+
super().__init__(app)
22+
self.request_logger = logging.getLogger('fastapi-request-logger')
23+
self.exclude_url_patterns = exclude_url_patterns
24+
logging.getLogger("uvicorn.access").propagate = False
25+
26+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
27+
log_request = is_not_match_any_pattern(request.url.path, self.exclude_url_patterns)
28+
29+
if not log_request:
30+
return await call_next(request)
31+
32+
request_info = json_logging.RequestInfo(request)
33+
response = await call_next(request)
34+
request_info.update_response_status(response)
35+
self.request_logger.info(
36+
"", extra={"request_info": request_info, "type": "request"}
37+
)
38+
return response
39+
40+
41+
class FastAPIAppRequestInstrumentationConfigurator(AppRequestInstrumentationConfigurator):
42+
def config(self, app, exclude_url_patterns=tuple()):
43+
if not isinstance(app, fastapi.FastAPI):
44+
raise RuntimeError("app is not a valid fastapi.FastAPI instance")
45+
46+
# Disable standard logging
47+
logging.getLogger('uvicorn.access').disabled = True
48+
49+
# noinspection PyAttributeOutsideInit
50+
self.request_logger = logging.getLogger('fastapi-request-logger')
51+
52+
app.add_middleware(JSONLoggingASGIMiddleware, exclude_url_patterns=exclude_url_patterns)
53+
54+
55+
class FastAPIRequestAdapter(RequestAdapter):
56+
@staticmethod
57+
def get_request_class_type():
58+
return starlette.requests.Request
59+
60+
@staticmethod
61+
def support_global_request_object():
62+
return False
63+
64+
@staticmethod
65+
def get_current_request():
66+
raise NotImplementedError
67+
68+
def get_remote_user(self, request: starlette.requests.Request):
69+
try:
70+
return request.user
71+
except AssertionError:
72+
return json_logging.EMPTY_VALUE
73+
74+
def get_http_header(self, request: starlette.requests.Request, header_name, default=None):
75+
try:
76+
if header_name in request.headers:
77+
return request.headers.get(header_name)
78+
except:
79+
pass
80+
return default
81+
82+
def set_correlation_id(self, request_, value):
83+
request_.state.correlation_id = value
84+
85+
def get_correlation_id_in_request_context(self, request: starlette.requests.Request):
86+
try:
87+
return request.state.correlation_id
88+
except AttributeError:
89+
return None
90+
91+
def get_protocol(self, request: starlette.requests.Request):
92+
protocol = str(request.scope.get('type', ''))
93+
http_version = str(request.scope.get('http_version', ''))
94+
if protocol.lower() == 'http' and http_version:
95+
return protocol.upper() + "/" + http_version
96+
return json_logging.EMPTY_VALUE
97+
98+
def get_path(self, request: starlette.requests.Request):
99+
return request.url.path
100+
101+
def get_content_length(self, request: starlette.requests.Request):
102+
return request.headers.get('content-length', json_logging.EMPTY_VALUE)
103+
104+
def get_method(self, request: starlette.requests.Request):
105+
return request.method
106+
107+
def get_remote_ip(self, request: starlette.requests.Request):
108+
return request.client.host
109+
110+
def get_remote_port(self, request: starlette.requests.Request):
111+
return request.client.port
112+
113+
114+
class FastAPIResponseAdapter(ResponseAdapter):
115+
def get_status_code(self, response: starlette.responses.Response):
116+
return response.status_code
117+
118+
def get_response_size(self, response: starlette.responses.Response):
119+
return response.headers.get('content-length', json_logging.EMPTY_VALUE)
120+
121+
def get_content_type(self, response: starlette.responses.Response):
122+
return response.headers.get('content-type', json_logging.EMPTY_VALUE)

json_logging/util.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ def __new__(cls, *args, **kw):
120120

121121
return cls._instance
122122

123-
def get_correlation_id(self, request=None,within_formatter=False):
123+
def get_correlation_id(self, request=None, within_formatter=False):
124124
"""
125125
Gets the correlation id from the header of the request. \
126126
It tries to search from json_logging.CORRELATION_ID_HEADERS list, one by one.\n

test-requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ flask
33
connexion[swagger-ui]
44
quart
55
sanic
6+
fastapi
7+
uvicorn
8+
requests
69
flake8
710
pytest
811
-e .

tests/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Organization of the test folder
2+
3+
```
4+
├───helpers Shared functionality for all tests
5+
├───smoketests A test script to run all API examples and see if they work
6+
│ ├───fastapi
7+
│ ├───flask
8+
│ ├───quart
9+
│ └───sanic
10+
└───test_*.py Unit tests
11+
```

tests/helpers/imports.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Helper functions related to module imports"""
2+
import sys
3+
4+
5+
def undo_imports_from_package(package: str):
6+
"""Removes all imported modules from the given package from sys.modules"""
7+
for k in sorted(sys.modules.keys(), key=lambda s: len(s), reverse=True):
8+
if k == package or k.startswith(package + '.'):
9+
del sys.modules[k]

tests/smoketests/fastapi/api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import datetime, logging, sys, json_logging, fastapi, uvicorn
2+
3+
app = fastapi.FastAPI()
4+
json_logging.init_fastapi(enable_json=True)
5+
json_logging.init_request_instrument(app)
6+
7+
# init the logger as usual
8+
logger = logging.getLogger("test-logger")
9+
logger.setLevel(logging.DEBUG)
10+
logger.addHandler(logging.StreamHandler(sys.stdout))
11+
12+
@app.get('/')
13+
def home():
14+
logger.info("test log statement")
15+
logger.info("test log statement with extra props", extra={'props': {"extra_property": 'extra_value'}})
16+
correlation_id = json_logging.get_correlation_id()
17+
return "Hello world : " + str(datetime.datetime.now())
18+
19+
if __name__ == "__main__":
20+
uvicorn.run(app, host='0.0.0.0', port=5000)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
fastapi
2+
uvicorn
3+
requests
4+
pytest
5+
-e .

tests/smoketests/flask/api.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import datetime, logging, sys, json_logging, flask
2+
3+
app = flask.Flask(__name__)
4+
json_logging.init_flask(enable_json=True)
5+
json_logging.init_request_instrument(app)
6+
7+
# init the logger as usual
8+
logger = logging.getLogger("test-logger")
9+
logger.setLevel(logging.DEBUG)
10+
logger.addHandler(logging.StreamHandler(sys.stdout))
11+
12+
@app.route('/')
13+
def home():
14+
logger.info("test log statement")
15+
logger.info("test log statement with extra props", extra={'props': {"extra_property": 'extra_value'}})
16+
correlation_id = json_logging.get_correlation_id()
17+
return "Hello world : " + str(datetime.datetime.now())
18+
19+
if __name__ == "__main__":
20+
app.run(host='0.0.0.0', port=int(5000), use_reloader=False)

0 commit comments

Comments
 (0)