Skip to content

Commit 491b5bc

Browse files
[WEB-5575]feat: enhance APITokenLogMiddleware to support logging to MongoDB (#8241)
* feat: enhance APITokenLogMiddleware to support logging to MongoDB - Added functionality to log external API requests to MongoDB, with a fallback to PostgreSQL if MongoDB is unavailable. - Implemented error handling for MongoDB connection and logging operations. - Introduced additional fields for MongoDB logs, including timestamps and user identifiers. - Refactored request logging logic to streamline the process and improve maintainability. * fix: improve MongoDB availability checks in APITokenLogMiddleware - Enhanced the logic for determining MongoDB availability by checking if the collection is not None. - Added a check for MongoDB configuration before attempting to retrieve the collection. - Updated error handling to ensure the middleware correctly reflects the state of MongoDB connectivity. * feat: implement logging functionality in logger_task for API activity - Added a new logger_task module to handle logging of API activity to MongoDB and PostgreSQL. - Introduced functions for safely decoding request/response bodies and processing logs based on MongoDB availability. - Refactored APITokenLogMiddleware to utilize the new logging functions, improving code organization and maintainability. * refactor: simplify MongoDB logging in logger_task and middleware - Removed direct dependency on MongoDB collection in log_to_mongo function, now retrieving it internally. - Updated process_logs to check MongoDB configuration before logging, enhancing error handling. - Cleaned up logger.py by removing unused imports related to MongoDB. * feat: add Celery task decorator to process_logs function in logger_task - Introduced the @shared_task decorator to the process_logs function, enabling asynchronous processing of log data. - Updated function signature to include a return type of None for clarity.
1 parent 368af22 commit 491b5bc

File tree

2 files changed

+139
-20
lines changed

2 files changed

+139
-20
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Python imports
2+
import logging
3+
from typing import Optional, Dict, Any
4+
5+
# Third party imports
6+
from pymongo.collection import Collection
7+
from celery import shared_task
8+
9+
# Django imports
10+
from plane.settings.mongo import MongoConnection
11+
from plane.utils.exception_logger import log_exception
12+
from plane.db.models import APIActivityLog
13+
14+
15+
logger = logging.getLogger("plane.worker")
16+
17+
18+
def get_mongo_collection() -> Optional[Collection]:
19+
"""
20+
Returns the MongoDB collection for external API activity logs.
21+
"""
22+
if not MongoConnection.is_configured():
23+
logger.info("MongoDB not configured")
24+
return None
25+
26+
try:
27+
return MongoConnection.get_collection("api_activity_logs")
28+
except Exception as e:
29+
logger.error(f"Error getting MongoDB collection: {str(e)}")
30+
log_exception(e)
31+
return None
32+
33+
34+
def safe_decode_body(content: bytes) -> Optional[str]:
35+
"""
36+
Safely decodes request/response body content, handling binary data.
37+
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
38+
Returns None if the content is None or empty.
39+
"""
40+
# If the content is None, return None
41+
if content is None:
42+
return None
43+
44+
# If the content is an empty bytes object, return None
45+
if content == b"":
46+
return None
47+
48+
# Check if content is binary by looking for common binary file signatures
49+
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
50+
return "[Binary Content]"
51+
52+
try:
53+
return content.decode("utf-8")
54+
except UnicodeDecodeError:
55+
return "[Could not decode content]"
56+
57+
58+
def log_to_mongo(log_document: Dict[str, Any]) -> bool:
59+
"""
60+
Logs the request to MongoDB if available.
61+
"""
62+
mongo_collection = get_mongo_collection()
63+
if mongo_collection is None:
64+
logger.error("MongoDB not configured")
65+
return False
66+
67+
try:
68+
mongo_collection.insert_one(log_document)
69+
return True
70+
except Exception as e:
71+
log_exception(e)
72+
return False
73+
74+
75+
def log_to_postgres(log_data: Dict[str, Any]) -> bool:
76+
"""
77+
Fallback to logging to PostgreSQL if MongoDB is unavailable.
78+
"""
79+
try:
80+
APIActivityLog.objects.create(**log_data)
81+
return True
82+
except Exception as e:
83+
log_exception(e)
84+
return False
85+
86+
87+
@shared_task
88+
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
89+
"""
90+
Process logs to save to MongoDB or Postgres based on the configuration
91+
"""
92+
93+
if MongoConnection.is_configured():
94+
log_to_mongo(mongo_log)
95+
else:
96+
log_to_postgres(log_data)

apps/api/plane/middleware/logger.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
# Django imports
66
from django.http import HttpRequest
7+
from django.utils import timezone
78

89
# Third party imports
910
from rest_framework.request import Request
1011

1112
# Module imports
1213
from plane.utils.ip_address import get_client_ip
13-
from plane.db.models import APIActivityLog
14+
from plane.utils.exception_logger import log_exception
15+
from plane.bgtasks.logger_task import process_logs
1416

1517
api_logger = logging.getLogger("plane.api.request")
1618

@@ -70,6 +72,10 @@ def __call__(self, request):
7072

7173

7274
class APITokenLogMiddleware:
75+
"""
76+
Middleware to log External API requests to MongoDB or PostgreSQL.
77+
"""
78+
7379
def __init__(self, get_response):
7480
self.get_response = get_response
7581

@@ -104,24 +110,41 @@ def _safe_decode_body(self, content):
104110
def process_request(self, request, response, request_body):
105111
api_key_header = "X-Api-Key"
106112
api_key = request.headers.get(api_key_header)
107-
# If the API key is present, log the request
108-
if api_key:
109-
try:
110-
APIActivityLog.objects.create(
111-
token_identifier=api_key,
112-
path=request.path,
113-
method=request.method,
114-
query_params=request.META.get("QUERY_STRING", ""),
115-
headers=str(request.headers),
116-
body=(self._safe_decode_body(request_body) if request_body else None),
117-
response_body=(self._safe_decode_body(response.content) if response.content else None),
118-
response_code=response.status_code,
119-
ip_address=get_client_ip(request=request),
120-
user_agent=request.META.get("HTTP_USER_AGENT", None),
121-
)
122-
123-
except Exception as e:
124-
api_logger.exception(e)
125-
# If the token does not exist, you can decide whether to log this as an invalid attempt
113+
114+
# If the API key is not present, return
115+
if not api_key:
116+
return
117+
118+
try:
119+
log_data = {
120+
"token_identifier": api_key,
121+
"path": request.path,
122+
"method": request.method,
123+
"query_params": request.META.get("QUERY_STRING", ""),
124+
"headers": str(request.headers),
125+
"body": self._safe_decode_body(request_body) if request_body else None,
126+
"response_body": self._safe_decode_body(response.content) if response.content else None,
127+
"response_code": response.status_code,
128+
"ip_address": get_client_ip(request=request),
129+
"user_agent": request.META.get("HTTP_USER_AGENT", None),
130+
}
131+
user_id = (
132+
str(request.user.id)
133+
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
134+
else None
135+
)
136+
# Additional fields for MongoDB
137+
mongo_log = {
138+
**log_data,
139+
"created_at": timezone.now(),
140+
"updated_at": timezone.now(),
141+
"created_by": user_id,
142+
"updated_by": user_id,
143+
}
144+
145+
process_logs.delay(log_data=log_data, mongo_log=mongo_log)
146+
147+
except Exception as e:
148+
log_exception(e)
126149

127150
return None

0 commit comments

Comments
 (0)