Skip to content
This repository was archived by the owner on Mar 13, 2023. It is now read-only.

Commit ca99bd3

Browse files
BarcoMasileMarco Basile
and
Marco Basile
authored
Added logging for Request/Response Body and Headers (#491)
* implemented before and after request to log details from http invocation * moved test under logging folder + added tests for new implementation * fixed import after moving the test file in previous commit + fixed logging related tests * fixed typing declaration * added info to the logged json for request/response + allow to specify no_log_paths * added mechanims to pass lambda event info to pcm flask context * removed unused import * added tests for valid cases * removed 'type' key from logged info + renamed no_logs_path to urls_deny_list * removed apigw request id from response + removed localproxy to carry the request id inside the app * adjusted test Co-authored-by: Marco Basile <[email protected]>
1 parent 38b1052 commit ca99bd3

File tree

8 files changed

+205
-31
lines changed

8 files changed

+205
-31
lines changed

api/logging/__init__.py

+27-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
from .logger import DefaultLogger
1+
from flask import Flask, request
2+
3+
from api.logging.http_info import log_request_body_and_headers, log_response_body_and_headers
24

35
VALID_LOG_LEVELS = {'debug', 'info', 'warning', 'error', 'critical'}
46

@@ -19,7 +21,30 @@ def parse_log_entry(_logger, entry):
1921

2022
return lowercase_level, message, extra
2123

24+
2225
def push_log_entry(_logger, level, message, extra):
2326
""" Logs a single log entry at the specified level """
2427
logging_fun = getattr(_logger, level, None)
25-
logging_fun(message, extra=extra)
28+
logging_fun(message, extra=extra)
29+
30+
31+
class RequestResponseLogging:
32+
def __init__(self, logger, app: Flask = None, urls_deny_list=['/logs']):
33+
self.logger = logger
34+
self.urls_deny_list = urls_deny_list
35+
if app:
36+
self.init_app(app)
37+
38+
def init_app(self, app):
39+
40+
def log_request():
41+
if request.path not in self.urls_deny_list:
42+
log_request_body_and_headers(self.logger, request)
43+
44+
def log_response(response = None):
45+
if request.path not in self.urls_deny_list:
46+
log_response_body_and_headers(self.logger, response)
47+
return response
48+
49+
app.before_request(log_request)
50+
app.after_request(log_response)

api/logging/http_info.py

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import Union
2+
3+
from flask import Request, Response
4+
5+
6+
def log_request_body_and_headers(_logger, request: Request):
7+
details = __get_http_info(request)
8+
details['path'] = request.path
9+
if request.args:
10+
details['params'] = request.args
11+
12+
if 'serverless.event' in request.environ:
13+
env = request.environ.get('serverless.event')
14+
if 'requestContext' in env and 'requestId' in env.get('requestContext'):
15+
details['apigw-request-id'] = env.get('requestContext').get('requestId')
16+
17+
_logger.info(details)
18+
19+
20+
def log_response_body_and_headers(_logger, response: Response):
21+
details = __get_http_info(response)
22+
_logger.info(details)
23+
24+
25+
def __get_http_info(r: Union[Request,Response]) -> dict:
26+
headers = __filter_headers(r.headers)
27+
details = {'headers': headers}
28+
29+
try:
30+
body = r.json
31+
if body:
32+
details['body'] = body
33+
except:
34+
pass
35+
36+
return details
37+
38+
39+
def __filter_headers(headers: dict):
40+
""" utility function to remove sensitive information from request headers """
41+
_headers = dict(headers)
42+
_headers.pop('Cookie', None)
43+
_headers.pop('X-CSRF-Token', None)
44+
return _headers

api/tests/logging/test_logger.py

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from unittest.mock import MagicMock
2+
3+
from flask import Response
4+
5+
from api.logging import RequestResponseLogging, log_request_body_and_headers, log_response_body_and_headers
6+
from api.logging.logger import DefaultLogger
7+
import logging
8+
9+
10+
def test_logger_level_prod():
11+
"""
12+
Given a DefaultLogger
13+
When not running local
14+
It should set log level to INFO
15+
"""
16+
17+
logger = DefaultLogger(is_running_local=False)
18+
assert logger.logger.getEffectiveLevel() == logging.INFO
19+
20+
21+
def test_logger_level_dev():
22+
"""
23+
Given a DefaultLogger
24+
When is running local
25+
It should set log level to DEBUG
26+
"""
27+
28+
logger = DefaultLogger(is_running_local=True)
29+
assert logger.logger.getEffectiveLevel() == logging.DEBUG
30+
31+
32+
def test_request_response_logging_extension(app):
33+
"""
34+
Given a Flask app
35+
when using the RequestResponseLogging extension
36+
it should present a log_request before func and a log_response after_func
37+
"""
38+
RequestResponseLogging(app)
39+
40+
before_func_names = __get_function_name_list(app.before_request_funcs)
41+
after_func_names = __get_function_name_list(app.after_request_funcs)
42+
43+
assert 'log_request' in before_func_names
44+
assert 'log_response' in after_func_names
45+
46+
def test_request_response_logging_execution_urls_deny_list(app, mocker):
47+
"""
48+
Given a Flask app
49+
when using the RequestResponseLogging extension
50+
when invoking a path from the specified no_logs_path
51+
it should not call the log_request_body_and_headers and log_response_body_and_headers functions
52+
"""
53+
54+
mock_log_request = mocker.patch('api.logging.http_info.log_request_body_and_headers')
55+
mock_log_response = mocker.patch('api.logging.http_info.log_response_body_and_headers')
56+
57+
RequestResponseLogging(app)
58+
59+
with app.test_request_context('/logs'):
60+
app.preprocess_request()
61+
app.process_response(Response('fake-response'))
62+
63+
mock_log_request.assert_not_called()
64+
mock_log_response.assert_not_called()
65+
66+
def test_request_response_logging_execution(app, mocker):
67+
"""
68+
Given a Flask app
69+
when using the RequestResponseLogging extension
70+
when invoking a path
71+
it should call the log_request_body_and_headers and log_response_body_and_headers functions
72+
"""
73+
mock_log_request = mocker.patch('api.logging.log_request_body_and_headers')
74+
mock_log_response = mocker.patch('api.logging.log_response_body_and_headers')
75+
76+
RequestResponseLogging(app)
77+
78+
with app.test_request_context('/'):
79+
app.preprocess_request()
80+
app.process_response(Response('fake-response'))
81+
82+
mock_log_request.assert_called_once()
83+
mock_log_response.assert_called_once()
84+
85+
def __get_function_name_list(functions):
86+
return list(fun.__name__ for app_name, funcs in functions.items() for fun in funcs)
87+
88+
89+
class MockRequest:
90+
headers = {'int_value': 100}
91+
args = {'region': 'eu-west-1'}
92+
json = {'username': '[email protected]'}
93+
path = '/fake-path'
94+
environ = {
95+
'serverless.event': {
96+
'requestContext': {
97+
'requestId': 'apigw-request-id'
98+
}
99+
}
100+
}
101+
102+
103+
def test_log_request_body_and_headers():
104+
mock_logger = MagicMock(wraps=DefaultLogger(True))
105+
log_request_body_and_headers(mock_logger, MockRequest())
106+
107+
expected_details = {
108+
'headers': {'int_value': 100},
109+
'body': {'username': '[email protected]'},
110+
'path': '/fake-path',
111+
'params': {'region': 'eu-west-1'},
112+
'apigw-request-id': 'apigw-request-id'
113+
}
114+
115+
mock_logger.info.assert_called_once_with(expected_details)
116+
117+
118+
def test_log_response_body_and_headers():
119+
mock_logger = MagicMock(wraps=DefaultLogger(True))
120+
log_response_body_and_headers(mock_logger, MockRequest())
121+
122+
expected_details = {
123+
'headers': {'int_value': 100},
124+
'body': {'username': '[email protected]'}
125+
}
126+
127+
mock_logger.info.assert_called_once_with(expected_details)

api/tests/test_logger.py

-25
This file was deleted.

api/tests/test_push_log.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def test_push_log_controller_with_valid_json_no_extra(client, caplog, mock_disab
1212
response = client.post('/logs', json=request_body)
1313

1414
assert response.status_code == 200
15-
assert trim_log(caplog) == expected_log
15+
assert expected_log in trim_log(caplog)
1616

1717

1818
def test_push_log_controller_with_valid_json_with_extra(client, caplog, mock_disable_auth, mock_csrf_needed):
@@ -24,4 +24,4 @@ def test_push_log_controller_with_valid_json_with_extra(client, caplog, mock_dis
2424
response = client.post('/logs', json=request_body)
2525

2626
assert response.status_code == 200
27-
assert trim_log(caplog) == expected_log
27+
assert expected_log in trim_log(caplog)

api/tests/test_revoke_cognito_refresh_token.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import pytest
44

55
from api.PclusterApiHandler import revoke_cognito_refresh_token
6-
from api.logging import DefaultLogger
6+
from api.logging.logger import DefaultLogger
77

88

99
@pytest.fixture

api/utils.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@
1515
from flask import Flask, Response, request, send_from_directory
1616
import requests
1717

18+
from api.pcm_globals import PCMGlobals, logger
1819
from api.exception import ExceptionHandler
19-
from api.pcm_globals import PCMGlobals
20+
from api.logging import RequestResponseLogging
2021
from api.security import SecurityHeaders
2122

2223
# needed to only allow tests to disable auth
@@ -99,6 +100,7 @@ def build_flask_app(name):
99100

100101
SecurityHeaders(app, running_local=is_running_local)
101102
ExceptionHandler(app, running_local=is_running_local)
103+
RequestResponseLogging(app=app, logger=logger)
102104

103105
return app
104106

awslambda/entrypoint.py

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import app
1616
import logging
17+
1718
from awslambda.serverless_wsgi import handle_request
1819

1920
# Initialize as a global to re-use across Lambda invocations

0 commit comments

Comments
 (0)