Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#2] Add option to log request response body #4

Merged
merged 9 commits into from
Jun 8, 2023
5 changes: 4 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ To use this with your project you need to follow these steps:
}

LOG_OUTGOING_REQUESTS_DB_SAVE = True # save logs enabled/disabled based on the boolean value
LOG_OUTGOING_REQUESTS_SAVE_BODY = True # save request/response body
LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT = True # log request/response body to STDOUT


#. Run the migrations

Expand All @@ -113,7 +116,7 @@ To use this with your project you need to follow these steps:
print(res.json())

#. Check stdout for the printable output, and navigate to ``/admin/log_outgoing_requests/outgoingrequestslog/`` to see
the saved log records
the saved log records. The settings for saving logs can by overridden under ``/admin/log_outgoing_requests/outgoingrequestslogconfig/``.
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved


Local development
Expand Down
3 changes: 3 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ Installation
}

LOG_OUTGOING_REQUESTS_DB_SAVE = True # save logs enabled/disabled based on the boolean value
LOG_OUTGOING_REQUESTS_SAVE_BODY = True # save request/response body
LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT = True # log request/response body to STDOUT


#. Run ``python manage.py migrate`` to create the necessary database tables.

Expand Down
30 changes: 28 additions & 2 deletions log_outgoing_requests/admin.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from django.contrib import admin
from django.utils.translation import gettext as _

from .models import OutgoingRequestsLog
from solo.admin import SingletonModelAdmin

from .models import OutgoingRequestsLog, OutgoingRequestsLogConfig


@admin.display(description="Response body")
def response_body(obj):
return f"{obj}".upper()
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved


@admin.register(OutgoingRequestsLog)
Expand All @@ -19,6 +26,8 @@ class OutgoingRequestsLogAdmin(admin.ModelAdmin):
"res_content_type",
"req_headers",
"res_headers",
"req_body",
"res_body",
"trace",
)
readonly_fields = fields
Expand All @@ -31,7 +40,7 @@ class OutgoingRequestsLogAdmin(admin.ModelAdmin):
"response_ms",
"timestamp",
)
list_filter = ("method", "status_code", "hostname")
list_filter = ("method", "status_code", "hostname", "timestamp")
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
search_fields = ("url", "params", "hostname")
date_hierarchy = "timestamp"
show_full_result_count = False
Expand All @@ -46,3 +55,20 @@ def query_params(self, obj):
return obj.query_params

query_params.short_description = _("Query parameters")

class Media:
css = {
"all": ("log_outgoing_requests/css/admin.css",),
}


@admin.register(OutgoingRequestsLogConfig)
class OutgoingRequestsLogConfigAdmin(SingletonModelAdmin):
fields = (
"save_to_db",
"save_body",
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't specify this at all, you want all config fields to be exposed anyway, no?

list_display = (
"save_to_db",
"save_body",
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

list_display doesn't mean anything with SingletonModelAdmin, there is no list view.

13 changes: 11 additions & 2 deletions log_outgoing_requests/formatters.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,39 @@
import logging
import textwrap

from django.conf import settings


class HttpFormatter(logging.Formatter):
def _formatHeaders(self, d):
return "\n".join(f"{k}: {v}" for k, v in d.items())

def _formatBody(self, content: dict, request_or_response: str) -> str:
if settings.LOG_OUTGOING_REQUESTS_LOG_BODY_TO_STDOUT:
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
return f"\n{request_or_response} body:\n{content}"
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
return ""

def formatMessage(self, record):
result = super().formatMessage(record)
if record.name == "requests":
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
result += textwrap.dedent(
"""
---------------- request ----------------
{req.method} {req.url}
{reqhdrs}
{reqhdrs} {request_body}

---------------- response ----------------
{res.status_code} {res.reason} {res.url}
{reshdrs}
{reshdrs} {response_body}

"""
).format(
req=record.req,
res=record.res,
reqhdrs=self._formatHeaders(record.req.headers),
reshdrs=self._formatHeaders(record.res.headers),
request_body=self._formatBody(record.req.body, "Request"),
response_body=self._formatBody(record.res.json(), "Response"),
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
)

return result
85 changes: 57 additions & 28 deletions log_outgoing_requests/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,70 @@

from django.conf import settings

ALLOWED_CONTENT_TYPES = [
"application/json",
"multipart/form-data",
"text/html",
"text/plain",
"",
None,
]
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved


class DatabaseOutgoingRequestsHandler(logging.Handler):
def emit(self, record):
if settings.LOG_OUTGOING_REQUESTS_DB_SAVE:
from .models import OutgoingRequestsLogConfig

config = OutgoingRequestsLogConfig.get_solo()

if config.save_to_db or settings.LOG_OUTGOING_REQUESTS_DB_SAVE:
from .models import OutgoingRequestsLog

trace = None

# save only the requests coming from the library requests
if record and record.getMessage() == "Outgoing request":
safe_req_headers = record.req.headers.copy()

if "Authorization" in safe_req_headers:
safe_req_headers["Authorization"] = "***hidden***"

if record.exc_info:
trace = traceback.format_exc()

parsed_url = urlparse(record.req.url)
kwargs = {
"url": record.req.url,
"hostname": parsed_url.hostname,
"params": parsed_url.params,
"status_code": record.res.status_code,
"method": record.req.method,
"req_content_type": record.req.headers.get("Content-Type", ""),
"res_content_type": record.res.headers.get("Content-Type", ""),
"timestamp": record.requested_at,
"response_ms": int(record.res.elapsed.total_seconds() * 1000),
"req_headers": self.format_headers(safe_req_headers),
"res_headers": self.format_headers(record.res.headers),
"trace": trace,
}

OutgoingRequestsLog.objects.create(**kwargs)
# skip requests not coming from the library requests
if not record or not record.getMessage() == "Outgoing request":
return

# skip requests with non-allowed content
request_content_type = record.req.headers.get("Content-Type", "")
response_content_type = record.res.headers.get("Content-Type", "")

if not (
request_content_type in ALLOWED_CONTENT_TYPES
and response_content_type in ALLOWED_CONTENT_TYPES
):
return
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved

safe_req_headers = record.req.headers.copy()
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved

if "Authorization" in safe_req_headers:
safe_req_headers["Authorization"] = "***hidden***"

if record.exc_info:
trace = traceback.format_exc()

parsed_url = urlparse(record.req.url)
kwargs = {
"url": record.req.url,
"hostname": parsed_url.hostname,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated to this feature, but this drops the port number from the host, probably this should log parsed.netloc instead of just the hostname

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that netloc would be preferable since it includes the port number if present, otherwise it's equivalent to hostname. However, since the model already includes a field for hostname, does it make sense to add this on top? Or should hostname be replaced with netloc?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed: replace hostname with netloc

"params": parsed_url.params,
"status_code": record.res.status_code,
"method": record.req.method,
"req_content_type": record.req.headers.get("Content-Type", ""),
"res_content_type": record.res.headers.get("Content-Type", ""),
"timestamp": record.requested_at,
"response_ms": int(record.res.elapsed.total_seconds() * 1000),
"req_headers": self.format_headers(safe_req_headers),
"res_headers": self.format_headers(record.res.headers),
"trace": trace,
}

if config.save_body or settings.LOG_OUTGOING_REQUESTS_SAVE_BODY:
kwargs["req_body"] = (record.req.body,)
kwargs["res_body"] = (record.res.json(),)

OutgoingRequestsLog.objects.create(**kwargs)

def format_headers(self, headers):
return "\n".join(f"{k}: {v}" for k, v in headers.items())
8 changes: 4 additions & 4 deletions log_outgoing_requests/log_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,16 @@ def install_outgoing_requests_logging():
Log all outgoing requests which are made by the library requests during a session.
"""

if hasattr(Session, "_original_request"):
if hasattr(Session, "_initial_request"):
pi-sigma marked this conversation as resolved.
Show resolved Hide resolved
logger.debug(
"Session is already patched OR has an ``_original_request`` attribute."
"Session is already patched OR has an ``_initial_request`` attribute."
)
return

Session._original_request = Session.request
Session._initial_request = Session.request

def new_request(self, *args, **kwargs):
self.hooks["response"].append(hook_requests_logging)
return self._original_request(*args, **kwargs)
return self._initial_request(*args, **kwargs)

Session.request = new_request
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Generated by Django 4.2.1 on 2023-05-09 07:48

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("log_outgoing_requests", "0001_initial"),
]

operations = [
migrations.CreateModel(
name="OutgoingRequestsLogConfig",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"save_to_db",
models.IntegerField(
blank=True,
choices=[(None, "Use default"), (0, "No"), (1, "Yes")],
help_text="Whether request logs should be saved to the database (default: True)",
null=True,
verbose_name="Save logs to database",
),
),
(
"save_body",
models.IntegerField(
blank=True,
choices=[(None, "Use default"), (0, "No"), (1, "Yes")],
help_text="Wheter the body of the request and response should be logged (default: False). This option is ignored if 'Save Logs to database' is set to False.",
null=True,
verbose_name="Save request + response body",
),
),
],
options={
"verbose_name": "Outgoing Requests Logs Configuration",
},
),
migrations.AlterModelOptions(
name="outgoingrequestslog",
options={
"permissions": [("can_view_logs", "Can view outgoing request logs")],
"verbose_name": "Outgoing Requests Log",
"verbose_name_plural": "Outgoing Requests Logs",
},
),
migrations.AddField(
model_name="outgoingrequestslog",
name="req_body",
field=models.TextField(
blank=True,
help_text="The request body.",
null=True,
verbose_name="Request body",
),
),
migrations.AddField(
model_name="outgoingrequestslog",
name="res_body",
field=models.JSONField(
blank=True,
help_text="The response body.",
null=True,
verbose_name="Response body",
),
),
]
Loading