Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/api/plane/bgtasks/logger_task.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Python imports
import logging
from typing import Optional, Dict, Any

# Third party imports
from pymongo.collection import Collection
from celery import shared_task

# Django imports
from plane.settings.mongo import MongoConnection
from plane.utils.exception_logger import log_exception
from plane.db.models import APIActivityLog


logger = logging.getLogger("plane.worker")


def get_mongo_collection() -> Optional[Collection]:
"""
Returns the MongoDB collection for external API activity logs.
"""
if not MongoConnection.is_configured():
logger.info("MongoDB not configured")
return None

try:
return MongoConnection.get_collection("api_activity_logs")
except Exception as e:
logger.error(f"Error getting MongoDB collection: {str(e)}")
log_exception(e)
return None


def safe_decode_body(content: bytes) -> Optional[str]:
"""
Safely decodes request/response body content, handling binary data.
Returns "[Binary Content]" if the content is binary, or a string representation of the content.
Returns None if the content is None or empty.
"""
# If the content is None, return None
if content is None:
return None

# If the content is an empty bytes object, return None
if content == b"":
return None

# Check if content is binary by looking for common binary file signatures
if content.startswith(b"\x89PNG") or content.startswith(b"\xff\xd8\xff") or content.startswith(b"%PDF"):
return "[Binary Content]"

try:
return content.decode("utf-8")
except UnicodeDecodeError:
return "[Could not decode content]"


def log_to_mongo(log_document: Dict[str, Any]) -> bool:
"""
Logs the request to MongoDB if available.
"""
mongo_collection = get_mongo_collection()
if mongo_collection is None:
logger.error("MongoDB not configured")
return False

try:
mongo_collection.insert_one(log_document)
return True
except Exception as e:
log_exception(e)
return False


def log_to_postgres(log_data: Dict[str, Any]) -> bool:
"""
Fallback to logging to PostgreSQL if MongoDB is unavailable.
"""
try:
APIActivityLog.objects.create(**log_data)
return True
except Exception as e:
log_exception(e)
return False


@shared_task
def process_logs(log_data: Dict[str, Any], mongo_log: Dict[str, Any]) -> None:
"""
Process logs to save to MongoDB or Postgres based on the configuration
"""

if MongoConnection.is_configured():
log_to_mongo(mongo_log)
else:
log_to_postgres(log_data)
63 changes: 43 additions & 20 deletions apps/api/plane/middleware/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@

# Django imports
from django.http import HttpRequest
from django.utils import timezone

# Third party imports
from rest_framework.request import Request

# Module imports
from plane.utils.ip_address import get_client_ip
from plane.db.models import APIActivityLog
from plane.utils.exception_logger import log_exception
from plane.bgtasks.logger_task import process_logs

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

Expand Down Expand Up @@ -70,6 +72,10 @@ def __call__(self, request):


class APITokenLogMiddleware:
"""
Middleware to log External API requests to MongoDB or PostgreSQL.
"""

def __init__(self, get_response):
self.get_response = get_response

Expand Down Expand Up @@ -104,24 +110,41 @@ def _safe_decode_body(self, content):
def process_request(self, request, response, request_body):
api_key_header = "X-Api-Key"
api_key = request.headers.get(api_key_header)
# If the API key is present, log the request
if api_key:
try:
APIActivityLog.objects.create(
token_identifier=api_key,
path=request.path,
method=request.method,
query_params=request.META.get("QUERY_STRING", ""),
headers=str(request.headers),
body=(self._safe_decode_body(request_body) if request_body else None),
response_body=(self._safe_decode_body(response.content) if response.content else None),
response_code=response.status_code,
ip_address=get_client_ip(request=request),
user_agent=request.META.get("HTTP_USER_AGENT", None),
)

except Exception as e:
api_logger.exception(e)
# If the token does not exist, you can decide whether to log this as an invalid attempt

# If the API key is not present, return
if not api_key:
return

try:
log_data = {
"token_identifier": api_key,
"path": request.path,
"method": request.method,
"query_params": request.META.get("QUERY_STRING", ""),
"headers": str(request.headers),
"body": self._safe_decode_body(request_body) if request_body else None,
"response_body": self._safe_decode_body(response.content) if response.content else None,
"response_code": response.status_code,
"ip_address": get_client_ip(request=request),
"user_agent": request.META.get("HTTP_USER_AGENT", None),
}
Comment thread
pablohashescobar marked this conversation as resolved.
Comment thread
pablohashescobar marked this conversation as resolved.
user_id = (
str(request.user.id)
if getattr(request, "user") and getattr(request.user, "is_authenticated", False)
else None
)
# Additional fields for MongoDB
mongo_log = {
**log_data,
"created_at": timezone.now(),
"updated_at": timezone.now(),
Comment thread
pablohashescobar marked this conversation as resolved.
"created_by": user_id,
"updated_by": user_id,
Comment thread
pablohashescobar marked this conversation as resolved.
}
Comment thread
pablohashescobar marked this conversation as resolved.

process_logs.delay(log_data=log_data, mongo_log=mongo_log)

Comment thread
pablohashescobar marked this conversation as resolved.
except Exception as e:
log_exception(e)

return None
Loading