Skip to content

Commit

Permalink
MP-432 Added improvements and provided support for older versions of …
Browse files Browse the repository at this point in the history
…Django, Python, and Graphene.
  • Loading branch information
Sheripov committed Sep 15, 2023
1 parent 893baf2 commit 0879487
Show file tree
Hide file tree
Showing 9 changed files with 352 additions and 452 deletions.
54 changes: 41 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,27 @@
}
```
2. Add `SetRequestToLoggerMiddleware` to your Django's `MIDDLEWARE` setting.
Example:

Example for Django middleware:
```python
MIDDLEWARE = [
...
"django_google_structured_logger.middleware.SetRequestToLoggerMiddleware",
...
# Ordering is important:
"django_google_structured_logger.middlewares.SetUserContextMiddleware", # Set user context to logger.
"django_google_structured_logger.middlewares.LogRequestAndResponseMiddleware", # Log request and response.
]
```
Example for GRAPHENE middleware:
```python
GRAPHENE = {
"MIDDLEWARE": [
...
# Ordering is important:
"django_google_structured_logger.graphene_middlewares.GrapheneSetUserContextMiddleware", # Set user context to logger.
"django_google_structured_logger.graphene_middlewares.GrapheneLogRequestAndResponseMiddleware", # Log request and response.
]
}
```
3. Ensure your Django project has the necessary configurations in the `settings.py`.

### Key Components:
Expand All @@ -70,16 +84,30 @@

These are the settings that can be customized for the middleware:

- **LOG_MAX_STR_LEN**: Maximum string length before data is abridged. Default is 200.
- **LOG_MAX_LIST_LEN**: Maximum list length before data is abridged. Default is 10.
- **LOG_EXCLUDED_ENDPOINTS**: List of endpoints to exclude from logging. Default is an empty list.
- **LOG_SENSITIVE_KEYS**: Regex patterns for keys which contain sensitive data. Defaults provided.
- **LOG_MASK_STYLE**: Style for masking sensitive data. Default is "partially".
- **LOG_MASK_CUSTOM_STYLE**: Custom style for masking if `LOG_MASK_STYLE` is set to "custom". Default is just the data itself.
- **LOG_MIDDLEWARE_ENABLED**: Enable or disable the logging middleware. Default is True.
- **LOG_EXCLUDED_HEADERS**: List of request headers to exclude from logging. Default is ["Authorization"].
- **LOG_USER_ID_FIELD**: Field name for user ID. Default is "id".
- **LOG_USER_EMAIL_FIELD**: Field name for user email. Default is "email".
- `LOG_MAX_STR_LEN`: Maximum string length before data is abridged. Default is `200`.
- `LOG_MAX_LIST_LEN`: Maximum list length before data is abridged. Default is `10`.
- `LOG_EXCLUDED_ENDPOINTS`: List of endpoints to exclude from logging. Default is an `empty list`.
- `LOG_SENSITIVE_KEYS`: Regex patterns for keys which contain sensitive data. Defaults `DEFAULT_SENSITIVE_KEYS`.
- `LOG_MASK_STYLE`: Style for masking sensitive data. Default is `"partially"`.
- `LOG_MIDDLEWARE_ENABLED`: Enable or disable the logging middleware. Default is `True`.
- `LOG_EXCLUDED_HEADERS`: List of request headers to exclude from logging. Defaults `DEFAULT_SENSITIVE_HEADERS`.
- `LOG_USER_ID_FIELD`: Field name for user ID. Default is `"id"`.
- `LOG_USER_EMAIL_FIELD`: Field name for user email. Default is `"email"`.
- `LOG_MAX_DEPTH`: Maximum depth for data to be logged. Default is `4`.

Note:
- All settings are imported from `django_google_structured_logger.settings`.


### Other Notes:
- `extra` kwargs passed to logger, for example:
```python
logger.info("some message", extra={"some_key": "some_data}
```
will be logged as structured data in the `jsonPayload` field in Google Cloud Logging.
Any data passed to extra kwargs will not be abridged or masked.
- `extra` kwargs passed to logger may override any default fields set by `GoogleFormatter`.


### Conclusion:

Expand Down
56 changes: 39 additions & 17 deletions django_google_structured_logger/formatter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from pythonjsonlogger import jsonlogger # type: ignore
from typing import Dict, Optional

from pythonjsonlogger import jsonlogger

from .storages import RequestStorage, get_current_request

Expand All @@ -8,8 +10,9 @@ class GoogleFormatter(jsonlogger.JsonFormatter):
google_operation_field = "logging.googleapis.com/operation"
google_labels_field = "logging.googleapis.com/labels"

def add_fields(self, log_record: dict, record, message_dict: dict):
"""Set Google default fields
def add_fields(self, log_record: Dict, record, message_dict: Dict):
"""
Set Google default fields.
List of Google supported fields:
https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry
Expand All @@ -24,34 +27,53 @@ def add_fields(self, log_record: dict, record, message_dict: dict):
- sourceLocation
"""
super().add_fields(log_record, record, message_dict)
current_request: RequestStorage | None = get_current_request()

current_request: Optional[RequestStorage] = get_current_request()

log_record["severity"] = record.levelname

log_record[self.google_labels_field] = {
"request_user_id": current_request.user_id() if current_request else None,
"request_user_email": current_request.user_email()
if current_request
else None,
**log_record.pop(self.google_labels_field, {}),
# Update each specialized field
self._set_labels(log_record, current_request)
self._set_operation(log_record, current_request)
self._set_source_location(log_record, record)

def _set_labels(self, log_record: Dict, current_request: Optional[RequestStorage]):
"""Set the Google labels in the log record."""
labels = {
"user_id": getattr(current_request, "user_id", None),
"user_display_field": getattr(current_request, "user_display_field", None),
**log_record.get(self.google_labels_field, {}),
**log_record.pop("labels", {}),
}
self.stringify_values(log_record[self.google_labels_field])
self.stringify_values(labels)
log_record[self.google_labels_field] = labels

log_record[self.google_operation_field] = {
"id": current_request.uuid if current_request else None,
"last": log_record.get("last_operation", False),
**log_record.pop(self.google_operation_field, {}),
def _set_operation(
self, log_record: Dict, current_request: Optional[RequestStorage]
):
"""Set the Google operation details in the log record."""
operation = {
"id": getattr(current_request, "uuid", None),
**{
k: v
for k, v in log_record.items()
if k in ["first_operation", "last_operation"] and v
},
**log_record.get(self.google_operation_field, {}),
**log_record.pop("operation", {}),
}
log_record[self.google_operation_field] = operation

def _set_source_location(self, log_record: Dict, record):
"""Set the Google source location in the log record."""
log_record[self.google_source_location_field] = {
"file": record.pathname,
"line": record.lineno,
"function": record.funcName,
"logger": record.name,
"logger_name": record.name,
}

@staticmethod
def stringify_values(dict_to_convert: dict):
def stringify_values(dict_to_convert: Dict):
for key in dict_to_convert:
dict_to_convert[key] = str(dict_to_convert[key])
56 changes: 56 additions & 0 deletions django_google_structured_logger/graphene_middlewares.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import json
import logging
import uuid
from typing import Any

from django.http import HttpResponse

from . import settings
from .middlewares import LogRequestAndResponseMiddleware
from .storages import RequestStorage, _current_request

logger = logging.getLogger(__name__)


class GrapheneSetUserContextMiddleware:
def resolve(self, next, root, info, **args):
user = info.context.user

_current_request.set(
RequestStorage(
user_id=self._get_user_attribute(user, settings.LOG_USER_ID_FIELD),
user_display_field=self._get_user_attribute(
user, settings.LOG_USER_DISPLAY_FIELD
),
uuid=str(uuid.uuid4()),
)
)

return next(root, info, **args)

@staticmethod
def _get_user_attribute(user, attribute) -> Any:
return getattr(user, attribute, None)


class GrapheneLogRequestAndResponseMiddleware(LogRequestAndResponseMiddleware):
def resolve(self, next, root, info, **args):
if not settings.LOG_MIDDLEWARE_ENABLED:
return next(root, info, **args)

# Graphene middleware doesn't give access to the raw request/response
# Instead, the `info` argument provides a `context` attribute which usually contains the request
request = info.context

self.process_request(request)

# Since there's no direct access to the response,
# we can't process the response in the same way.
# But we can capture the result of the GraphQL execution.
result = next(root, info, **args)
# Here, `result` is the data returned from your GraphQL resolvers.
# We're wrapping it in a Django HttpResponse to use the existing process_response function.
fake_response = HttpResponse(content=json.dumps(result))
self.process_response(request, fake_response)

return result
Loading

0 comments on commit 0879487

Please sign in to comment.