From 85116bc8ca654b97fb664c7afb2d96f3dbe01bb9 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 22 Jan 2026 19:00:18 +0100 Subject: [PATCH 1/6] Add request tracing and query logging with bottleneck identification Implement comprehensive request tracing to identify internal and external bottlenecks: - New trace module with context managers for timing segments - DRF auto-instrumentation for permissions, serializers, querysets, throttles - External call tracking for GitHub, Cloudinary, GenLayer, reCAPTCHA - Smart logging: shows breakdown only for slow requests (>100ms) or errors - DEBUG=true shows all requests; DEBUG=false shows only 5xx errors - Removed unused functions to keep code lean and maintainable --- backend/api/apps.py | 5 + backend/contributions/recaptcha_field.py | 5 +- backend/tally/middleware/__init__.py | 13 +- backend/tally/middleware/api_logging.py | 77 +++++++--- backend/tally/middleware/drf_tracing.py | 142 +++++++++++++++++ backend/tally/middleware/logging_utils.py | 2 + backend/tally/middleware/tracing.py | 144 ++++++++++++++++++ backend/tally/settings.py | 5 + backend/users/cloudinary_service.py | 34 +++-- backend/users/genlayer_service.py | 10 +- backend/users/github_oauth.py | 21 ++- backend/users/views.py | 4 +- .../validators/genlayer_validators_service.py | 20 ++- 13 files changed, 422 insertions(+), 60 deletions(-) create mode 100644 backend/tally/middleware/drf_tracing.py create mode 100644 backend/tally/middleware/tracing.py diff --git a/backend/api/apps.py b/backend/api/apps.py index 66656fd2..2e09e94b 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -4,3 +4,8 @@ class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' + + def ready(self): + """Initialize DRF tracing when the app is ready.""" + from tally.middleware.drf_tracing import install_drf_tracing + install_drf_tracing() diff --git a/backend/contributions/recaptcha_field.py b/backend/contributions/recaptcha_field.py index acb88e48..d1634adf 100644 --- a/backend/contributions/recaptcha_field.py +++ b/backend/contributions/recaptcha_field.py @@ -9,6 +9,8 @@ from django_recaptcha.fields import ReCaptchaField as DjangoReCaptchaField from django_recaptcha.widgets import ReCaptchaV2Checkbox +from tally.middleware.tracing import trace_external + class ReCaptchaField(serializers.Field): """ @@ -55,7 +57,8 @@ def to_internal_value(self, data): try: # Use django-recaptcha's validation # The field expects the token value directly - cleaned_value = self.django_field.clean(data) + with trace_external('recaptcha', 'verify'): + cleaned_value = self.django_field.clean(data) return cleaned_value except Exception as e: # Handle various validation errors diff --git a/backend/tally/middleware/__init__.py b/backend/tally/middleware/__init__.py index 802db835..f9f2dd26 100644 --- a/backend/tally/middleware/__init__.py +++ b/backend/tally/middleware/__init__.py @@ -1,5 +1,14 @@ -"""Tally middleware package for logging.""" +"""Tally middleware package for logging and tracing.""" from .api_logging import APILoggingMiddleware from .db_logging import DBLoggingMiddleware +from .tracing import ( + trace_segment, + trace_external, +) -__all__ = ['APILoggingMiddleware', 'DBLoggingMiddleware'] +__all__ = [ + 'APILoggingMiddleware', + 'DBLoggingMiddleware', + 'trace_segment', + 'trace_external', +] diff --git a/backend/tally/middleware/api_logging.py b/backend/tally/middleware/api_logging.py index 7434a99d..d2accf6f 100644 --- a/backend/tally/middleware/api_logging.py +++ b/backend/tally/middleware/api_logging.py @@ -1,7 +1,9 @@ """ API Layer Logging Middleware. -Logs HTTP requests/responses for the [API] layer (Frontend <-> Backend). +Logs HTTP requests/responses for the [API] layer with smart trace breakdown. +- DEBUG=true: Logs all requests; shows breakdown for slow requests (>100ms) +- DEBUG=false: Logs only 5xx errors with breakdown """ import time from django.conf import settings @@ -13,6 +15,13 @@ clear_correlation_id, format_bytes, ) +from .tracing import ( + init_tracing, + clear_tracing, + get_segments, + format_breakdown, + should_expand_trace, +) logger = get_api_logger() @@ -20,10 +29,11 @@ class APILoggingMiddleware: """ - Middleware to log HTTP requests and responses. + Middleware to log HTTP requests and responses with trace breakdown. - DEBUG=true: Logs all requests with method, path, status, duration, size - DEBUG=false: Logs only errors (4xx, 5xx) + Logging behavior: + - DEBUG=true: All requests logged; breakdown shown for requests > 100ms + - DEBUG=false: Only 5xx errors logged (always with breakdown) """ # Paths to skip logging @@ -43,9 +53,10 @@ def __call__(self, request): if any(request.path.startswith(path) for path in self.SKIP_PATHS): return self.get_response(request) - # Generate and set correlation ID + # Initialize request tracking correlation_id = generate_correlation_id() set_correlation_id(correlation_id) + init_tracing() # Start timing start_time = time.time() @@ -62,14 +73,14 @@ def __call__(self, request): # Get DB stats if available (set by DBLoggingMiddleware) db_query_count = getattr(request, '_db_query_count', 0) db_time_ms = getattr(request, '_db_time_ms', 0) - logic_time_ms = duration_ms - db_time_ms - # Build log message with timing breakdown - if db_query_count > 0: - timing_info = f"{duration_ms:.0f}ms (db: {db_time_ms:.0f}ms/{db_query_count}q, logic: {logic_time_ms:.0f}ms)" - else: - timing_info = f"{duration_ms:.0f}ms" + # Get trace segments + segments = get_segments() + + # Build timing info string + timing_info = self._build_timing_info(duration_ms, db_time_ms, db_query_count) + # Build log message log_message = ( f"{request.method} {request.path} " f"{response.status_code} " @@ -77,20 +88,42 @@ def __call__(self, request): f"{format_bytes(response_size)}" ) - # Log based on status code and DEBUG setting - is_error = response.status_code >= 400 + # Determine logging behavior + is_server_error = response.status_code >= 500 + is_slow = should_expand_trace(duration_ms) + has_segments = len(segments) > 0 - if is_error: - # Always log errors - if response.status_code >= 500: - logger.error(log_message) - else: - logger.warning(log_message) + if is_server_error: + # Always log 5xx errors with breakdown + logger.error(log_message) + if has_segments: + self._log_breakdown(segments, level='error') elif settings.DEBUG: - # In debug mode, log all requests - logger.info(log_message) + # In DEBUG mode, log all requests + logger.debug(log_message) + # Show breakdown for slow requests + if is_slow and has_segments: + self._log_breakdown(segments, level='debug') - # Clear correlation ID + # Clear request tracking clear_correlation_id() + clear_tracing() return response + + def _build_timing_info(self, duration_ms: float, db_time_ms: float, db_query_count: int) -> str: + """Build the timing info string for the log message.""" + if db_query_count > 0: + return f"{duration_ms:.0f}ms (db: {db_time_ms:.0f}ms/{db_query_count}q)" + return f"{duration_ms:.0f}ms" + + def _log_breakdown(self, segments: list, level: str = 'debug') -> None: + """Log the trace breakdown with indentation.""" + breakdown = format_breakdown(segments) + if breakdown: + # Log each line of the breakdown + for line in breakdown.split('\n'): + if level == 'error': + logger.error(line) + else: + logger.debug(line) diff --git a/backend/tally/middleware/drf_tracing.py b/backend/tally/middleware/drf_tracing.py new file mode 100644 index 00000000..bb26eb91 --- /dev/null +++ b/backend/tally/middleware/drf_tracing.py @@ -0,0 +1,142 @@ +""" +DRF Automatic Instrumentation. + +Monkey-patches Django REST Framework to automatically trace key lifecycle methods. +This enables bottleneck identification without modifying existing view code. +""" +import functools +from typing import Callable, Any + +from .tracing import trace_segment + +# Flag to ensure we only install once +_installed = False + + +def _wrap_method(original_method: Callable, segment_name: str) -> Callable: + """ + Wrap a method to trace its execution time. + + Args: + original_method: The original method to wrap + segment_name: Name for the trace segment + + Returns: + Wrapped method that traces execution time + """ + @functools.wraps(original_method) + def wrapper(*args, **kwargs) -> Any: + with trace_segment(segment_name): + return original_method(*args, **kwargs) + return wrapper + + +def _wrap_method_with_dynamic_name(original_method: Callable, name_prefix: str) -> Callable: + """ + Wrap a method with a dynamic segment name based on the class. + + Args: + original_method: The original method to wrap + name_prefix: Prefix for the segment name (class name will be appended) + + Returns: + Wrapped method that traces execution time + """ + @functools.wraps(original_method) + def wrapper(self, *args, **kwargs) -> Any: + class_name = self.__class__.__name__ + segment_name = f"{name_prefix}:{class_name}" + with trace_segment(segment_name): + return original_method(self, *args, **kwargs) + return wrapper + + +def install_drf_tracing() -> None: + """ + Install automatic tracing on DRF classes. + + Should be called once at application startup (e.g., in AppConfig.ready()). + Instruments the following DRF methods: + - APIView.check_permissions() -> "permissions" + - APIView.check_throttles() -> "throttles" + - GenericAPIView.get_serializer() -> "serializer:init" + - GenericAPIView.get_queryset() -> "queryset:{ClassName}" + - Serializer.to_representation() -> "serializer:render" + - Serializer.to_internal_value() -> "serializer:parse" + """ + global _installed + if _installed: + return + + try: + from rest_framework.views import APIView + from rest_framework.generics import GenericAPIView + from rest_framework.serializers import Serializer + + # Instrument APIView methods + if hasattr(APIView, 'check_permissions'): + APIView.check_permissions = _wrap_method( + APIView.check_permissions, + 'permissions' + ) + + if hasattr(APIView, 'check_throttles'): + APIView.check_throttles = _wrap_method( + APIView.check_throttles, + 'throttles' + ) + + # Instrument GenericAPIView methods + if hasattr(GenericAPIView, 'get_queryset'): + GenericAPIView.get_queryset = _wrap_method_with_dynamic_name( + GenericAPIView.get_queryset, + 'queryset' + ) + + if hasattr(GenericAPIView, 'get_serializer'): + GenericAPIView.get_serializer = _wrap_method( + GenericAPIView.get_serializer, + 'serializer:init' + ) + + # Instrument Serializer methods + if hasattr(Serializer, 'to_representation'): + original_to_representation = Serializer.to_representation + + @functools.wraps(original_to_representation) + def traced_to_representation(self, instance): + # Only trace if this is the top-level serializer call + # to avoid tracing nested serializer calls + if not getattr(self, '_tracing_render', False): + self._tracing_render = True + try: + with trace_segment('serializer:render'): + return original_to_representation(self, instance) + finally: + self._tracing_render = False + return original_to_representation(self, instance) + + Serializer.to_representation = traced_to_representation + + if hasattr(Serializer, 'to_internal_value'): + original_to_internal = Serializer.to_internal_value + + @functools.wraps(original_to_internal) + def traced_to_internal_value(self, data): + # Only trace if this is the top-level serializer call + if not getattr(self, '_tracing_parse', False): + self._tracing_parse = True + try: + with trace_segment('serializer:parse'): + return original_to_internal(self, data) + finally: + self._tracing_parse = False + return original_to_internal(self, data) + + Serializer.to_internal_value = traced_to_internal_value + + _installed = True + + except ImportError: + # DRF not installed, skip instrumentation + pass diff --git a/backend/tally/middleware/logging_utils.py b/backend/tally/middleware/logging_utils.py index ffc99a5d..5e0b5959 100644 --- a/backend/tally/middleware/logging_utils.py +++ b/backend/tally/middleware/logging_utils.py @@ -56,6 +56,7 @@ class LayeredFormatter(logging.Formatter): 'tally.api': 'API', 'tally.db': 'DB', 'tally.app': 'APP', + 'tally.trace': 'TRACE', } def format(self, record: logging.LogRecord) -> str: @@ -86,6 +87,7 @@ class LayeredJSONFormatter(logging.Formatter): 'tally.api': 'API', 'tally.db': 'DB', 'tally.app': 'APP', + 'tally.trace': 'TRACE', } def format(self, record: logging.LogRecord) -> str: diff --git a/backend/tally/middleware/tracing.py b/backend/tally/middleware/tracing.py new file mode 100644 index 00000000..dfbe8b2e --- /dev/null +++ b/backend/tally/middleware/tracing.py @@ -0,0 +1,144 @@ +""" +Request Tracing Module. + +Provides utilities for timing code segments within a request lifecycle. +Enables identification of bottlenecks by breaking down request processing +into measurable segments (internal operations and external calls). +""" +import threading +import time +from contextlib import contextmanager + +# Threshold for expanding trace breakdown in logs +EXPAND_THRESHOLD_MS = 100 + +# Thread-local storage for current request's trace data +_trace_data = threading.local() + + +def init_tracing() -> None: + """Initialize tracing for a new request.""" + _trace_data.segments = [] + _trace_data.start_time = time.time() + + +def clear_tracing() -> None: + """Clear tracing data after request completes.""" + _trace_data.segments = [] + _trace_data.start_time = None + + +def record_segment(name: str, duration_ms: float, is_external: bool = False) -> None: + """ + Record a completed segment timing. + + Args: + name: Segment identifier (e.g., 'permissions', 'serialization', 'ext:github:check_star') + duration_ms: Duration in milliseconds + is_external: Whether this is an external API call + """ + segments = getattr(_trace_data, 'segments', None) + if segments is None: + _trace_data.segments = [] + segments = _trace_data.segments + + segments.append({ + 'name': name, + 'duration_ms': duration_ms, + 'is_external': is_external, + }) + + +def get_segments() -> list: + """Get all recorded segments for current request.""" + return getattr(_trace_data, 'segments', []) + + +@contextmanager +def trace_segment(name: str): + """ + Context manager for timing a code segment. + + Usage: + with trace_segment('fetch_user_data'): + user_data = fetch_user_data() + + Args: + name: Identifier for the segment (e.g., 'queryset', 'validation') + """ + start_time = time.time() + try: + yield + finally: + duration_ms = (time.time() - start_time) * 1000 + record_segment(name, duration_ms, is_external=False) + + +@contextmanager +def trace_external(service: str, operation: str): + """ + Context manager for timing external API calls. + + Usage: + with trace_external('github', 'check_star'): + response = requests.get(github_url) + + Args: + service: External service name (e.g., 'github', 'cloudinary', 'genlayer') + operation: Operation being performed (e.g., 'check_star', 'upload') + """ + segment_name = f"ext:{service}:{operation}" + start_time = time.time() + try: + yield + finally: + duration_ms = (time.time() - start_time) * 1000 + record_segment(segment_name, duration_ms, is_external=True) + + +def format_breakdown(segments: list, indent: str = " ") -> str: + """ + Format segments as an indented breakdown for logging. + + Args: + segments: List of segment dicts from get_segments() + indent: Indentation string + + Returns: + Multi-line string with tree-formatted breakdown + """ + if not segments: + return "" + + lines = [] + sorted_segments = sorted(segments, key=lambda s: s['duration_ms'], reverse=True) + + for i, segment in enumerate(sorted_segments): + is_last = i == len(sorted_segments) - 1 + prefix = "└─" if is_last else "├─" + name = segment['name'] + duration = segment['duration_ms'] + + # Mark slowest segment as bottleneck if it's significantly slower + bottleneck_marker = "" + if i == 0 and duration > EXPAND_THRESHOLD_MS and len(sorted_segments) > 1: + second_slowest = sorted_segments[1]['duration_ms'] if len(sorted_segments) > 1 else 0 + if duration > second_slowest * 2: + bottleneck_marker = " <- bottleneck" + + lines.append(f"{indent}{prefix} {name}: {duration:.0f}ms{bottleneck_marker}") + + return "\n".join(lines) + + +def should_expand_trace(duration_ms: float) -> bool: + """ + Determine if the trace breakdown should be shown. + + Args: + duration_ms: Total request duration in milliseconds + + Returns: + True if breakdown should be displayed + """ + return duration_ms >= EXPAND_THRESHOLD_MS diff --git a/backend/tally/settings.py b/backend/tally/settings.py index 70e5773f..b50ed155 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -360,6 +360,11 @@ def get_port_from_argv(): 'level': 'DEBUG' if DEBUG else 'WARNING', 'propagate': False, }, + 'tally.trace': { + 'handlers': ['console'], + 'level': 'DEBUG' if DEBUG else 'WARNING', + 'propagate': False, + }, # Silence noisy Django loggers 'django': { 'handlers': ['console'], diff --git a/backend/users/cloudinary_service.py b/backend/users/cloudinary_service.py index 1faae0e7..8e5c1f2e 100644 --- a/backend/users/cloudinary_service.py +++ b/backend/users/cloudinary_service.py @@ -5,6 +5,7 @@ import time from tally.middleware.logging_utils import get_app_logger +from tally.middleware.tracing import trace_external logger = get_app_logger('cloudinary') @@ -63,13 +64,14 @@ def upload_profile_image(cls, image_file, user_id: int) -> Dict: upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') timestamp = int(time.time()) - result = cloudinary.uploader.unsigned_upload( - image_file, - upload_preset, - public_id=f"user_{user_id}_profile_{timestamp}", - folder="tally/profiles" - ) - + with trace_external('cloudinary', 'upload_profile'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"user_{user_id}_profile_{timestamp}", + folder="tally/profiles" + ) + # Build URL with transformations transformation_str = "w_400,h_400,c_fill,g_face,q_auto:good,f_auto" base_url = result['secure_url'] @@ -114,13 +116,14 @@ def upload_banner_image(cls, image_file, user_id: int) -> Dict: upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') timestamp = int(time.time()) - result = cloudinary.uploader.unsigned_upload( - image_file, - upload_preset, - public_id=f"user_{user_id}_banner_{timestamp}", - folder="tally/banners" - ) - + with trace_external('cloudinary', 'upload_banner'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"user_{user_id}_banner_{timestamp}", + folder="tally/banners" + ) + # Build URL with transformations transformation_str = "w_1500,h_500,c_fill,g_center,q_auto:good,f_auto" base_url = result['secure_url'] @@ -162,7 +165,8 @@ def delete_image(cls, public_id: str) -> bool: try: cls.configure() - result = cloudinary.uploader.destroy(public_id) + with trace_external('cloudinary', 'delete'): + result = cloudinary.uploader.destroy(public_id) return result.get('result') == 'ok' except Exception as e: logger.error(f"Failed to delete image: {str(e)}") diff --git a/backend/users/genlayer_service.py b/backend/users/genlayer_service.py index 606e78f2..8c82c246 100644 --- a/backend/users/genlayer_service.py +++ b/backend/users/genlayer_service.py @@ -7,6 +7,7 @@ from web3 import Web3 from tally.middleware.logging_utils import get_app_logger +from tally.middleware.tracing import trace_external logger = get_app_logger('genlayer') @@ -74,10 +75,11 @@ def get_user_deployments(self, wallet_address: str) -> Dict[str, Any]: logger.debug("Making request to Studio API") try: - response = self.client.provider.make_request( - method="sim_getTransactionsForAddress", - params=[wallet_address] - ) + with trace_external('genlayer', 'get_transactions'): + response = self.client.provider.make_request( + method="sim_getTransactionsForAddress", + params=[wallet_address] + ) except Exception as api_error: # Log the raw error for debugging logger.error(f"Raw API error: {str(api_error)}") diff --git a/backend/users/github_oauth.py b/backend/users/github_oauth.py index 89c052e7..09e6761f 100644 --- a/backend/users/github_oauth.py +++ b/backend/users/github_oauth.py @@ -18,6 +18,7 @@ from cryptography.fernet import Fernet, InvalidToken from .models import User from tally.middleware.logging_utils import get_app_logger +from tally.middleware.tracing import trace_external logger = get_app_logger('github_oauth') @@ -176,9 +177,10 @@ def github_oauth_callback(request): headers = {'Accept': 'application/json'} try: - token_response = requests.post(token_url, data=token_params, headers=headers) - token_response.raise_for_status() - token_data = token_response.json() + with trace_external('github', 'token_exchange'): + token_response = requests.post(token_url, data=token_params, headers=headers) + token_response.raise_for_status() + token_data = token_response.json() if 'error' in token_data: logger.error("GitHub token exchange error") @@ -214,9 +216,10 @@ def github_oauth_callback(request): 'Accept': 'application/json' } - user_response = requests.get(user_url, headers=user_headers) - user_response.raise_for_status() - github_user = user_response.json() + with trace_external('github', 'get_user'): + user_response = requests.get(user_url, headers=user_headers) + user_response.raise_for_status() + github_user = user_response.json() # Get user from state token # User ID must be present in state since we require authentication to initiate OAuth @@ -345,13 +348,15 @@ def check_repo_star(request): if user.github_access_token and 'Authorization' in headers: # Authenticated API: GET /user/starred/{owner}/{repo} returns 204 if starred, 404 if not url = f'https://api.github.com/user/starred/{owner}/{repo}' - response = requests.get(url, headers=headers) + with trace_external('github', 'check_star'): + response = requests.get(url, headers=headers) has_starred = response.status_code == 204 else: # Public API: Check in user's starred repos list # This is less efficient but works without authentication url = f'https://api.github.com/users/{user.github_username}/starred' - response = requests.get(url, headers={'Accept': 'application/json'}) + with trace_external('github', 'check_star_public'): + response = requests.get(url, headers={'Accept': 'application/json'}) if response.status_code == 200: starred_repos = response.json() diff --git a/backend/users/views.py b/backend/users/views.py index 36757fe5..a6875057 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -17,6 +17,7 @@ import string from tally.middleware.logging_utils import get_app_logger +from tally.middleware.tracing import trace_external logger = get_app_logger('users') @@ -504,7 +505,8 @@ def complete_builder_journey(self, request): from django.conf import settings web3 = Web3(Web3.HTTPProvider(settings.VALIDATOR_RPC_URL)) checksum_address = Web3.to_checksum_address(user.address) - balance_wei = web3.eth.get_balance(checksum_address) + with trace_external('web3', 'get_balance'): + balance_wei = web3.eth.get_balance(checksum_address) balance_eth = web3.from_wei(balance_wei, 'ether') if balance_eth <= 0: diff --git a/backend/validators/genlayer_validators_service.py b/backend/validators/genlayer_validators_service.py index c7f0de2b..815960d5 100644 --- a/backend/validators/genlayer_validators_service.py +++ b/backend/validators/genlayer_validators_service.py @@ -7,6 +7,7 @@ from web3 import Web3 from tally.middleware.logging_utils import get_app_logger +from tally.middleware.tracing import trace_external logger = get_app_logger('validators') @@ -172,7 +173,8 @@ def fetch_active_validators(self) -> List[str]: List of validator wallet addresses """ try: - validators = self.staking_contract.functions.activeValidators().call() + with trace_external('web3', 'active_validators'): + validators = self.staking_contract.functions.activeValidators().call() # Filter out invalid addresses valid_validators = [ addr for addr in validators @@ -199,9 +201,10 @@ def fetch_banned_validators(self, start_index: int = 0, size: int = 1000) -> Lis List of validator data with address, untilEpochBanned, permanently_banned """ try: - banned_list = self.staking_contract.functions.getAllBannedValidators( - start_index, size - ).call() + with trace_external('web3', 'banned_validators'): + banned_list = self.staking_contract.functions.getAllBannedValidators( + start_index, size + ).call() result = [] for banned in banned_list: @@ -228,7 +231,8 @@ def fetch_validator_view(self, validator_address: str) -> Optional[Dict[str, Any """ try: checksum_address = Web3.to_checksum_address(validator_address) - view = self.staking_contract.functions.validatorView(checksum_address).call() + with trace_external('web3', 'validator_view'): + view = self.staking_contract.functions.validatorView(checksum_address).call() return { 'left': view[0], @@ -257,7 +261,8 @@ def fetch_operator_for_wallet(self, wallet_address: str) -> Optional[str]: """ try: contract = self._get_validator_wallet_contract(wallet_address) - operator = contract.functions.operator().call() + with trace_external('web3', 'get_operator'): + operator = contract.functions.operator().call() # Check for zero address if operator.lower() == '0x0000000000000000000000000000000000000000': @@ -280,7 +285,8 @@ def fetch_validator_identity(self, wallet_address: str) -> Optional[Dict[str, An """ try: contract = self._get_validator_wallet_contract(wallet_address) - identity = contract.functions.getIdentity().call() + with trace_external('web3', 'get_identity'): + identity = contract.functions.getIdentity().call() return { 'moniker': identity[0], From 9d15f5ffcf5524db8c33cc7b663861e859760e7f Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 22 Jan 2026 19:05:25 +0100 Subject: [PATCH 2/6] Implement Request for Startups feature with admin-only management - Add StartupRequest model with document support (PDF, images, URLs) - Create read-only API endpoints for listing and detail views - Design frontend listing matching Missions component styling - Implement detail page with markdown description and document previews - Support expandable PDF/image previews with Cloudinary transformation - Display URL resources inline with chain icon and external link indicator - Configure Django admin interface for full CRUD management - Remove frontend add/edit/delete functionality (managed through admin) --- backend/api/urls.py | 3 +- backend/contributions/admin.py | 37 +- .../migrations/0030_startuprequest.py | 32 ++ backend/contributions/models.py | 48 ++- backend/contributions/serializers.py | 26 +- backend/contributions/urls.py | 3 +- backend/contributions/views.py | 26 +- frontend/src/App.svelte | 2 + frontend/src/components/Icons.svelte | 4 +- .../src/components/StartupRequests.svelte | 106 +++++ frontend/src/lib/api.js | 4 +- frontend/src/routes/Contributions.svelte | 6 + .../src/routes/StartupRequestDetail.svelte | 363 ++++++++++++++++++ package-lock.json | 2 +- 14 files changed, 652 insertions(+), 10 deletions(-) create mode 100644 backend/contributions/migrations/0030_startuprequest.py create mode 100644 frontend/src/components/StartupRequests.svelte create mode 100644 frontend/src/routes/StartupRequestDetail.svelte diff --git a/backend/api/urls.py b/backend/api/urls.py index 9d72d499..883e1e0f 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,7 +1,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from users.views import UserViewSet -from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet +from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView @@ -17,6 +17,7 @@ router.register(r'submissions', SubmittedContributionViewSet, basename='submission') router.register(r'steward-submissions', StewardSubmissionViewSet, basename='steward-submission') router.register(r'missions', MissionViewSet, basename='mission') +router.register(r'startup-requests', StartupRequestViewSet, basename='startup-request') # The API URLs are now determined automatically by the router urlpatterns = [ diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index 0f406744..afa9275d 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from datetime import datetime -from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission +from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest from .validator_forms import CreateValidatorForm from leaderboard.models import GlobalLeaderboardMultiplier @@ -628,3 +628,38 @@ def get_status(self, obj): else: return format_html(' Inactive') get_status.short_description = 'Status' + + +@admin.register(StartupRequest) +class StartupRequestAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'get_status', 'order', 'created_at') + list_filter = ('is_active', 'created_at') + search_fields = ('title', 'description', 'short_description') + readonly_fields = ('id', 'created_at', 'updated_at') + list_editable = ('order',) + ordering = ('order', '-created_at') + + fieldsets = ( + (None, { + 'fields': ('id', 'title', 'is_active', 'order') + }), + ('Content', { + 'fields': ('short_description', 'description'), + 'description': 'Short description is shown in the listing. Full description supports Markdown.' + }), + ('Documents', { + 'fields': ('documents',), + 'description': 'JSON array of document objects: [{"title": "...", "url": "...", "type": "pdf|image"}]' + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def get_status(self, obj): + if obj.is_active: + return format_html(' Active') + else: + return format_html(' Inactive') + get_status.short_description = 'Status' diff --git a/backend/contributions/migrations/0030_startuprequest.py b/backend/contributions/migrations/0030_startuprequest.py new file mode 100644 index 00000000..4a1a5cbb --- /dev/null +++ b/backend/contributions/migrations/0030_startuprequest.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.10 on 2026-01-22 16:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0029_submittedcontribution_assigned_to_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='StartupRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(help_text='Title of the startup request', max_length=200)), + ('description', models.TextField(help_text='Full description (supports Markdown)')), + ('short_description', models.CharField(help_text='Brief description shown in listing (plain text)', max_length=300)), + ('documents', models.JSONField(blank=True, default=list, help_text='Array of document objects: [{title, url, type}]')), + ('is_active', models.BooleanField(default=True, help_text='Whether this startup request is currently visible')), + ('order', models.PositiveIntegerField(default=0, help_text='Display order (lower numbers appear first)')), + ], + options={ + 'verbose_name': 'Startup Request', + 'verbose_name_plural': 'Startup Requests', + 'ordering': ['order', '-created_at'], + }, + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 707794ea..900a1969 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -505,5 +505,51 @@ def get_active_highlights(cls, contribution_type=None, user=None, limit=5): 'contribution__user', 'contribution__contribution_type' ) - + return queryset[:limit] + + +class StartupRequest(BaseModel): + """ + Represents a startup idea/opportunity for the community to pursue. + Displayed in the builder contributions section as informational content. + """ + title = models.CharField( + max_length=200, + help_text="Title of the startup request" + ) + description = models.TextField( + help_text="Full description (supports Markdown)" + ) + short_description = models.CharField( + max_length=300, + help_text="Brief description shown in listing (plain text)" + ) + documents = models.JSONField( + default=list, + blank=True, + help_text="Array of document objects: [{title, url, type}]" + ) + is_active = models.BooleanField( + default=True, + help_text="Whether this startup request is currently visible" + ) + order = models.PositiveIntegerField( + default=0, + help_text="Display order (lower numbers appear first)" + ) + + class Meta: + ordering = ['order', '-created_at'] + verbose_name = "Startup Request" + verbose_name_plural = "Startup Requests" + + def __str__(self): + return self.title + + @classmethod + def get_active_requests(cls): + """ + Get all active startup requests ordered by display order. + """ + return cls.objects.filter(is_active=True).order_by('order', '-created_at') \ No newline at end of file diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index fecbfdb5..5ca269e5 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission +from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest from users.serializers import UserSerializer, LightUserSerializer from users.models import User from .recaptcha_field import ReCaptchaField @@ -619,3 +619,27 @@ class Meta: def get_is_active(self, obj): return obj.is_active() + + +class StartupRequestListSerializer(serializers.ModelSerializer): + """ + Lightweight serializer for listing startup requests. + """ + class Meta: + model = StartupRequest + fields = ['id', 'title', 'short_description', 'is_active', 'order', 'created_at'] + read_only_fields = ['id', 'created_at'] + + +class StartupRequestDetailSerializer(serializers.ModelSerializer): + """ + Full serializer for startup request detail view. + Includes all fields for rendering the full page with markdown and documents. + """ + class Meta: + model = StartupRequest + fields = [ + 'id', 'title', 'description', 'short_description', + 'documents', 'is_active', 'order', 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at'] diff --git a/backend/contributions/urls.py b/backend/contributions/urls.py index cebb621a..3ccb89a1 100644 --- a/backend/contributions/urls.py +++ b/backend/contributions/urls.py @@ -3,7 +3,7 @@ from .views import ( ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, SubmissionListView, submission_review_view, - MissionViewSet + MissionViewSet, StartupRequestViewSet ) app_name = 'contributions' @@ -15,6 +15,7 @@ router.register(r'evidence', EvidenceViewSet) router.register(r'submissions', SubmittedContributionViewSet, basename='submission') router.register(r'missions', MissionViewSet, basename='mission') +router.register(r'startup-requests', StartupRequestViewSet, basename='startup-request') urlpatterns = [ # API URLs diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 8490cbc9..9f7881c9 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -12,12 +12,12 @@ from django.contrib import messages from django.views.generic import ListView from django.utils.decorators import method_decorator -from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission +from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, StartupRequest from .serializers import (ContributionTypeSerializer, ContributionSerializer, EvidenceSerializer, SubmittedContributionSerializer, SubmittedEvidenceSerializer, ContributionHighlightSerializer, StewardSubmissionSerializer, StewardSubmissionReviewSerializer, - MissionSerializer) + MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer) from .forms import SubmissionReviewForm from .permissions import IsSteward from leaderboard.models import GlobalLeaderboardMultiplier @@ -1170,3 +1170,25 @@ def get_queryset(self): queryset = queryset.filter(contribution_type__category__slug=category) return queryset + + +class StartupRequestViewSet(viewsets.ReadOnlyModelViewSet): + """ + Read-only ViewSet for startup requests. + Management is done through Django admin. + """ + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + """ + Return active startup requests ordered by display order. + """ + return StartupRequest.get_active_requests() + + def get_serializer_class(self): + """ + Use appropriate serializer based on action. + """ + if self.action == 'retrieve': + return StartupRequestDetailSerializer + return StartupRequestListSerializer diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 723b1621..a30afc90 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -47,6 +47,7 @@ import Referrals from './routes/Referrals.svelte'; import Supporters from './routes/Supporters.svelte'; import GlobalDashboard from './components/GlobalDashboard.svelte'; + import StartupRequestDetail from './routes/StartupRequestDetail.svelte'; // Define routes const routes = { @@ -75,6 +76,7 @@ '/builders/highlights': Highlights, '/builders/leaderboard': Leaderboard, '/builders/welcome': BuilderWelcome, + '/builders/startup-requests/:id': StartupRequestDetail, // Validators routes '/validators': Dashboard, diff --git a/frontend/src/components/Icons.svelte b/frontend/src/components/Icons.svelte index b5d6eea7..edde579b 100644 --- a/frontend/src/components/Icons.svelte +++ b/frontend/src/components/Icons.svelte @@ -73,7 +73,9 @@ sparkle: 'M22 0c0 16.9-9.1 32-22 32c12.9 0 22 15.1 22 32c0-16.9 9.1-32 22-32c-12.9 0-22-15.1-22-32M53 0c0 8.4-4.6 16-11 16c6.4 0 11 7.6 11 16c0-8.4 4.6-16 11-16c-6.4 0-11-7.6-11-16M48 32c0 8.4-4.6 16-11 16c6.4 0 11 7.6 11 16c0-8.4 4.6-16 11-16c-6.4 0-11-7.6-11-16', - network: 'M27 21.75c-0.795 0.004-1.538 0.229-2.169 0.616l0.018-0.010-2.694-2.449c0.724-1.105 1.154-2.459 1.154-3.913 0-1.572-0.503-3.027-1.358-4.212l0.015 0.021 3.062-3.062c0.57 0.316 1.249 0.503 1.971 0.508h0.002c2.347 0 4.25-1.903 4.25-4.25s-1.903-4.25-4.25-4.25c-2.347 0-4.25 1.903-4.25 4.25v0c0.005 0.724 0.193 1.403 0.519 1.995l-0.011-0.022-3.062 3.062c-1.147-0.84-2.587-1.344-4.144-1.344-0.868 0-1.699 0.157-2.467 0.443l0.049-0.016-0.644-1.17c0.726-0.757 1.173-1.787 1.173-2.921 0-2.332-1.891-4.223-4.223-4.223s-4.223 1.891-4.223 4.223c0 2.332 1.891 4.223 4.223 4.223 0.306 0 0.605-0.033 0.893-0.095l-0.028 0.005 0.642 1.166c-1.685 1.315-2.758 3.345-2.758 5.627 0 0.605 0.076 1.193 0.218 1.754l-0.011-0.049-0.667 0.283c-0.78-0.904-1.927-1.474-3.207-1.474-2.334 0-4.226 1.892-4.226 4.226s1.892 4.226 4.226 4.226c2.334 0 4.226-1.892 4.226-4.226 0-0.008-0-0.017-0-0.025v0.001c-0.008-0.159-0.023-0.307-0.046-0.451l0.003 0.024 0.667-0.283c1.303 2.026 3.547 3.349 6.1 3.349 1.703 0 3.268-0.589 4.503-1.574l-0.015 0.011 2.702 2.455c-0.258 0.526-0.41 1.144-0.414 1.797v0.001c0 2.347 1.903 4.25 4.25 4.25s4.25-1.903 4.25-4.25c0-2.347-1.903-4.25-4.25-4.25v0zM8.19 5c0-0.966 0.784-1.75 1.75-1.75s1.75 0.784 1.75 1.75c0 0.966-0.784 1.75-1.75 1.75v0c-0.966-0.001-1.749-0.784-1.75-1.75v-0zM5 22.42c-0.966-0.001-1.748-0.783-1.748-1.749s0.783-1.749 1.749-1.749c0.966 0 1.748 0.782 1.749 1.748v0c-0.001 0.966-0.784 1.749-1.75 1.75h-0zM27 3.25c0.966 0 1.75 0.784 1.75 1.75s-0.784 1.75-1.75 1.75c-0.966 0-1.75-0.784-1.75-1.75v0c0.001-0.966 0.784-1.749 1.75-1.75h0zM11.19 16c0-0.001 0-0.002 0-0.003 0-2.655 2.152-4.807 4.807-4.807 1.328 0 2.53 0.539 3.4 1.409l0.001 0.001 0.001 0.001c0.87 0.87 1.407 2.072 1.407 3.399 0 2.656-2.153 4.808-4.808 4.808s-4.808-2.153-4.808-4.808c0-0 0-0 0-0v0zM27 27.75c-0.966 0-1.75-0.784-1.75-1.75s0.784-1.75 1.75-1.75c0.966 0 1.75 0.784 1.75 1.75v0c-0.001 0.966-0.784 1.749-1.75 1.75h-0z' + network: 'M27 21.75c-0.795 0.004-1.538 0.229-2.169 0.616l0.018-0.010-2.694-2.449c0.724-1.105 1.154-2.459 1.154-3.913 0-1.572-0.503-3.027-1.358-4.212l0.015 0.021 3.062-3.062c0.57 0.316 1.249 0.503 1.971 0.508h0.002c2.347 0 4.25-1.903 4.25-4.25s-1.903-4.25-4.25-4.25c-2.347 0-4.25 1.903-4.25 4.25v0c0.005 0.724 0.193 1.403 0.519 1.995l-0.011-0.022-3.062 3.062c-1.147-0.84-2.587-1.344-4.144-1.344-0.868 0-1.699 0.157-2.467 0.443l0.049-0.016-0.644-1.17c0.726-0.757 1.173-1.787 1.173-2.921 0-2.332-1.891-4.223-4.223-4.223s-4.223 1.891-4.223 4.223c0 2.332 1.891 4.223 4.223 4.223 0.306 0 0.605-0.033 0.893-0.095l-0.028 0.005 0.642 1.166c-1.685 1.315-2.758 3.345-2.758 5.627 0 0.605 0.076 1.193 0.218 1.754l-0.011-0.049-0.667 0.283c-0.78-0.904-1.927-1.474-3.207-1.474-2.334 0-4.226 1.892-4.226 4.226s1.892 4.226 4.226 4.226c2.334 0 4.226-1.892 4.226-4.226 0-0.008-0-0.017-0-0.025v0.001c-0.008-0.159-0.023-0.307-0.046-0.451l0.003 0.024 0.667-0.283c1.303 2.026 3.547 3.349 6.1 3.349 1.703 0 3.268-0.589 4.503-1.574l-0.015 0.011 2.702 2.455c-0.258 0.526-0.41 1.144-0.414 1.797v0.001c0 2.347 1.903 4.25 4.25 4.25s4.25-1.903 4.25-4.25c0-2.347-1.903-4.25-4.25-4.25v0zM8.19 5c0-0.966 0.784-1.75 1.75-1.75s1.75 0.784 1.75 1.75c0 0.966-0.784 1.75-1.75 1.75v0c-0.966-0.001-1.749-0.784-1.75-1.75v-0zM5 22.42c-0.966-0.001-1.748-0.783-1.748-1.749s0.783-1.749 1.749-1.749c0.966 0 1.748 0.782 1.749 1.748v0c-0.001 0.966-0.784 1.749-1.75 1.75h-0zM27 3.25c0.966 0 1.75 0.784 1.75 1.75s-0.784 1.75-1.75 1.75c-0.966 0-1.75-0.784-1.75-1.75v0c0.001-0.966 0.784-1.749 1.75-1.75h0zM11.19 16c0-0.001 0-0.002 0-0.003 0-2.655 2.152-4.807 4.807-4.807 1.328 0 2.53 0.539 3.4 1.409l0.001 0.001 0.001 0.001c0.87 0.87 1.407 2.072 1.407 3.399 0 2.656-2.153 4.808-4.808 4.808s-4.808-2.153-4.808-4.808c0-0 0-0 0-0v0zM27 27.75c-0.966 0-1.75-0.784-1.75-1.75s0.784-1.75 1.75-1.75c0.966 0 1.75 0.784 1.75 1.75v0c-0.001 0.966-0.784 1.749-1.75 1.75h-0z', + + megaphone: 'M18 8h2a1 1 0 011 1v6a1 1 0 01-1 1h-2l-5 4V4l5 4zm-7 4a3 3 0 11-6 0 3 3 0 016 0z' }; diff --git a/frontend/src/components/StartupRequests.svelte b/frontend/src/components/StartupRequests.svelte new file mode 100644 index 00000000..3ca82585 --- /dev/null +++ b/frontend/src/components/StartupRequests.svelte @@ -0,0 +1,106 @@ + + + + +{#if !loading && startupRequests.length > 0} + {@const colors = getPioneerContributionsColors($currentCategory)} + + +
+ +
+
+ +

Request for Startups

+
+

+ Startup ideas we believe could thrive in the GenLayer ecosystem.
+ The GenLayer team is ready to support founders by opening doors and providing guidance. +

+
+ + + {#each startupRequests as request} +
+ +
+ + + +
+ + +

+ {request.short_description} +

+
+ {/each} +
+{:else if loading} + +
+
+
+
+
+
+
+
+
+
+
+
+
+{/if} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index c3b8867a..f13eb9ba 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -97,7 +97,9 @@ export const contributionsAPI = { data: { count: res.data.contribution_count } })), getMissions: (params) => api.get('/missions/', { params }), - getMission: (id) => api.get(`/missions/${id}/`) + getMission: (id) => api.get(`/missions/${id}/`), + getStartupRequests: () => api.get('/startup-requests/'), + getStartupRequest: (id) => api.get(`/startup-requests/${id}/`) }; diff --git a/frontend/src/routes/Contributions.svelte b/frontend/src/routes/Contributions.svelte index 481de639..073f5d81 100644 --- a/frontend/src/routes/Contributions.svelte +++ b/frontend/src/routes/Contributions.svelte @@ -1,6 +1,7 @@ + + + +
+ {#if loading} + +
+
+
+
+
+
+
+
+
+
+
+
+ {:else if error} + +
+ +

Failed to Load

+

{error}

+ +
+ {:else if startupRequest} + + + + +
+

+ {startupRequest.title} +

+ {#if startupRequest.short_description} +

{startupRequest.short_description}

+ {/if} +
+ + +
+ +
+
+
+

+ + Description +

+
+
+
+ {@html renderMarkdown(startupRequest.description)} +
+
+
+
+ + +
+ {#if startupRequest.documents && startupRequest.documents.length > 0} +
+
+

+ + Resources +

+
+
+ {#each startupRequest.documents as doc, index} +
+ {#if doc.type === 'link'} + + + + + +
+ {doc.title} + {doc.url} +
+ +
+ {:else} + +
+
+ + {doc.title} +
+
+ + + + +
+
+ + + {#if expandedDoc === index} +
+ {#if doc.type === 'pdf'} + + {:else if doc.type === 'image'} + {doc.title} + {/if} +
+ {/if} + {/if} +
+ {/each} +
+
+ {/if} +
+
+ {/if} +
diff --git a/package-lock.json b/package-lock.json index 43462773..9856290c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "lisbon", + "name": "rabat", "lockfileVersion": 3, "requires": true, "packages": { From e753e48bae7f8a406b7b21b955678c7e0452b9a2 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Thu, 22 Jan 2026 19:16:48 +0100 Subject: [PATCH 3/6] Clean up tracing output: single log entry and aggregate segments - Remove per-item serializer:render tracing that caused noise in list views - Output breakdown as single log entry instead of multiple lines - Aggregate duplicate segment names with count (e.g., "serializer: 80ms x10") - Rename serializer:init to just serializer for cleaner output --- backend/tally/middleware/api_logging.py | 24 ++++---------- backend/tally/middleware/drf_tracing.py | 43 ++----------------------- backend/tally/middleware/tracing.py | 27 ++++++++++++++-- 3 files changed, 32 insertions(+), 62 deletions(-) diff --git a/backend/tally/middleware/api_logging.py b/backend/tally/middleware/api_logging.py index d2accf6f..33b22f3e 100644 --- a/backend/tally/middleware/api_logging.py +++ b/backend/tally/middleware/api_logging.py @@ -93,17 +93,16 @@ def __call__(self, request): is_slow = should_expand_trace(duration_ms) has_segments = len(segments) > 0 + # Build complete log message with optional breakdown + show_breakdown = has_segments and (is_server_error or (settings.DEBUG and is_slow)) + if show_breakdown: + breakdown = format_breakdown(segments) + log_message = f"{log_message}\n{breakdown}" + if is_server_error: - # Always log 5xx errors with breakdown logger.error(log_message) - if has_segments: - self._log_breakdown(segments, level='error') elif settings.DEBUG: - # In DEBUG mode, log all requests logger.debug(log_message) - # Show breakdown for slow requests - if is_slow and has_segments: - self._log_breakdown(segments, level='debug') # Clear request tracking clear_correlation_id() @@ -116,14 +115,3 @@ def _build_timing_info(self, duration_ms: float, db_time_ms: float, db_query_cou if db_query_count > 0: return f"{duration_ms:.0f}ms (db: {db_time_ms:.0f}ms/{db_query_count}q)" return f"{duration_ms:.0f}ms" - - def _log_breakdown(self, segments: list, level: str = 'debug') -> None: - """Log the trace breakdown with indentation.""" - breakdown = format_breakdown(segments) - if breakdown: - # Log each line of the breakdown - for line in breakdown.split('\n'): - if level == 'error': - logger.error(line) - else: - logger.debug(line) diff --git a/backend/tally/middleware/drf_tracing.py b/backend/tally/middleware/drf_tracing.py index bb26eb91..ea760bad 100644 --- a/backend/tally/middleware/drf_tracing.py +++ b/backend/tally/middleware/drf_tracing.py @@ -59,10 +59,8 @@ def install_drf_tracing() -> None: Instruments the following DRF methods: - APIView.check_permissions() -> "permissions" - APIView.check_throttles() -> "throttles" - - GenericAPIView.get_serializer() -> "serializer:init" + - GenericAPIView.get_serializer() -> "serializer" - GenericAPIView.get_queryset() -> "queryset:{ClassName}" - - Serializer.to_representation() -> "serializer:render" - - Serializer.to_internal_value() -> "serializer:parse" """ global _installed if _installed: @@ -71,7 +69,6 @@ def install_drf_tracing() -> None: try: from rest_framework.views import APIView from rest_framework.generics import GenericAPIView - from rest_framework.serializers import Serializer # Instrument APIView methods if hasattr(APIView, 'check_permissions'): @@ -96,45 +93,9 @@ def install_drf_tracing() -> None: if hasattr(GenericAPIView, 'get_serializer'): GenericAPIView.get_serializer = _wrap_method( GenericAPIView.get_serializer, - 'serializer:init' + 'serializer' ) - # Instrument Serializer methods - if hasattr(Serializer, 'to_representation'): - original_to_representation = Serializer.to_representation - - @functools.wraps(original_to_representation) - def traced_to_representation(self, instance): - # Only trace if this is the top-level serializer call - # to avoid tracing nested serializer calls - if not getattr(self, '_tracing_render', False): - self._tracing_render = True - try: - with trace_segment('serializer:render'): - return original_to_representation(self, instance) - finally: - self._tracing_render = False - return original_to_representation(self, instance) - - Serializer.to_representation = traced_to_representation - - if hasattr(Serializer, 'to_internal_value'): - original_to_internal = Serializer.to_internal_value - - @functools.wraps(original_to_internal) - def traced_to_internal_value(self, data): - # Only trace if this is the top-level serializer call - if not getattr(self, '_tracing_parse', False): - self._tracing_parse = True - try: - with trace_segment('serializer:parse'): - return original_to_internal(self, data) - finally: - self._tracing_parse = False - return original_to_internal(self, data) - - Serializer.to_internal_value = traced_to_internal_value - _installed = True except ImportError: diff --git a/backend/tally/middleware/tracing.py b/backend/tally/middleware/tracing.py index dfbe8b2e..a0955a41 100644 --- a/backend/tally/middleware/tracing.py +++ b/backend/tally/middleware/tracing.py @@ -99,6 +99,7 @@ def trace_external(service: str, operation: str): def format_breakdown(segments: list, indent: str = " ") -> str: """ Format segments as an indented breakdown for logging. + Aggregates duplicate segment names to reduce noise. Args: segments: List of segment dicts from get_segments() @@ -110,14 +111,34 @@ def format_breakdown(segments: list, indent: str = " ") -> str: if not segments: return "" - lines = [] - sorted_segments = sorted(segments, key=lambda s: s['duration_ms'], reverse=True) + # Aggregate segments with the same name + aggregated = {} + for segment in segments: + name = segment['name'] + if name in aggregated: + aggregated[name]['duration_ms'] += segment['duration_ms'] + aggregated[name]['count'] += 1 + else: + aggregated[name] = { + 'name': name, + 'duration_ms': segment['duration_ms'], + 'is_external': segment['is_external'], + 'count': 1, + } + + # Sort by duration descending + sorted_segments = sorted(aggregated.values(), key=lambda s: s['duration_ms'], reverse=True) + lines = [] for i, segment in enumerate(sorted_segments): is_last = i == len(sorted_segments) - 1 prefix = "└─" if is_last else "├─" name = segment['name'] duration = segment['duration_ms'] + count = segment['count'] + + # Show count if more than 1 call + count_suffix = f" x{count}" if count > 1 else "" # Mark slowest segment as bottleneck if it's significantly slower bottleneck_marker = "" @@ -126,7 +147,7 @@ def format_breakdown(segments: list, indent: str = " ") -> str: if duration > second_slowest * 2: bottleneck_marker = " <- bottleneck" - lines.append(f"{indent}{prefix} {name}: {duration:.0f}ms{bottleneck_marker}") + lines.append(f"{indent}{prefix} {name}: {duration:.0f}ms{count_suffix}{bottleneck_marker}") return "\n".join(lines) From 8aaf8021eb64b815b4cc828c7ba7dce9f39e7d75 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Mon, 26 Jan 2026 10:07:25 +0200 Subject: [PATCH 4/6] Implement GitHub-style search bar for steward submission filtering Replaces individual filter dropdowns (type, assigned, status) and checkbox exclusion filters with a unified search bar supporting tag-based syntax. Users can now filter submissions using queries like: - status:pending assigned:me exclude:medium.com - type:blog-post -exclude:spam has:url - from:alice min-contributions:2 sort:-created Adds three new frontend libraries: - searchParser.js: Parses GitHub-style queries into structured filters - searchToParams.js: Maps parsed filters to API parameters - StewardSearchBar.svelte: Reusable search component with autocomplete and help Extends backend filters with generic content exclusion/inclusion: - exclude_content/include_content: Search any text in notes/evidence - exclude_state: Exclude submissions with specific status - only_empty_evidence: Show only submissions without evidence URLs - exclude_username: Exclude submissions from specific users Keeps Status dropdown as quick-access filter. Syncs search query to URL for shareable links. --- backend/contributions/views.py | 81 +++- .../src/components/StewardSearchBar.svelte | 409 ++++++++++++++++++ frontend/src/lib/searchParser.js | 192 ++++++++ frontend/src/lib/searchToParams.js | 119 +++++ frontend/src/routes/StewardSubmissions.svelte | 194 +++------ package-lock.json | 2 +- 6 files changed, 836 insertions(+), 161 deletions(-) create mode 100644 frontend/src/components/StewardSearchBar.svelte create mode 100644 frontend/src/lib/searchParser.js create mode 100644 frontend/src/lib/searchToParams.js diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 9f7881c9..efed900b 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -774,9 +774,13 @@ def add_evidence(self, request, pk=None): class StewardSubmissionFilterSet(FilterSet): """Custom filterset for steward submission filtering.""" username_search = CharFilter(method='filter_username') + exclude_username = CharFilter(method='filter_exclude_username') assigned_to = CharFilter(method='filter_assigned_to') - exclude_medium_blogpost = BooleanFilter(method='filter_exclude_medium_blogpost') + exclude_content = CharFilter(method='filter_exclude_content') + include_content = CharFilter(method='filter_include_content') exclude_empty_evidence = BooleanFilter(method='filter_exclude_empty_evidence') + only_empty_evidence = BooleanFilter(method='filter_only_empty_evidence') + exclude_state = CharFilter(method='filter_exclude_state') min_accepted_contributions = NumberFilter(method='filter_min_accepted_contributions') def filter_username(self, queryset, name, value): @@ -789,6 +793,16 @@ def filter_username(self, queryset, name, value): ) return queryset + def filter_exclude_username(self, queryset, name, value): + """Exclude submissions from users matching the search.""" + if value: + return queryset.exclude( + Q(user__name__icontains=value) | + Q(user__email__icontains=value) | + Q(user__address__icontains=value) + ) + return queryset + def filter_assigned_to(self, queryset, name, value): """Filter by assigned steward or unassigned.""" if value == 'null' or value == 'unassigned': @@ -797,21 +811,44 @@ def filter_assigned_to(self, queryset, name, value): return queryset.filter(assigned_to_id=value) return queryset - def filter_exclude_medium_blogpost(self, queryset, name, value): - """Exclude submissions that have Medium blog post URLs in evidence or notes.""" + def filter_exclude_state(self, queryset, name, value): + """Exclude submissions with specific state.""" if value: - # Subquery: check if submission has any evidence with medium.com URL - has_medium_evidence = Evidence.objects.filter( - submitted_contribution=OuterRef('pk'), - url__icontains='medium.com' - ) - # Exclude submissions where: - # - Any evidence URL contains medium.com OR - # - Notes contain medium.com - return queryset.exclude( - Exists(has_medium_evidence) | - Q(notes__icontains='medium.com') - ) + return queryset.exclude(state=value) + return queryset + + def filter_exclude_content(self, queryset, name, value): + """Exclude submissions containing text in notes or evidence. Supports comma-separated values.""" + if value: + for term in value.split(','): + term = term.strip() + if term: + has_matching_evidence = Evidence.objects.filter( + submitted_contribution=OuterRef('pk') + ).filter( + Q(url__icontains=term) | Q(notes__icontains=term) + ) + queryset = queryset.exclude( + Exists(has_matching_evidence) | + Q(notes__icontains=term) + ) + return queryset + + def filter_include_content(self, queryset, name, value): + """Include ONLY submissions containing text in notes or evidence. Supports comma-separated values.""" + if value: + for term in value.split(','): + term = term.strip() + if term: + has_matching_evidence = Evidence.objects.filter( + submitted_contribution=OuterRef('pk') + ).filter( + Q(url__icontains=term) | Q(notes__icontains=term) + ) + queryset = queryset.filter( + Exists(has_matching_evidence) | + Q(notes__icontains=term) + ) return queryset def filter_exclude_empty_evidence(self, queryset, name, value): @@ -832,6 +869,20 @@ def filter_exclude_empty_evidence(self, queryset, name, value): ) return queryset + def filter_only_empty_evidence(self, queryset, name, value): + """Include ONLY submissions without URL evidence (inverse of exclude_empty_evidence).""" + if value: + has_url_evidence = Evidence.objects.filter( + submitted_contribution=OuterRef('pk'), + url__gt='' + ) + return queryset.filter( + ~Exists(has_url_evidence) & + ~Q(notes__icontains='http://') & + ~Q(notes__icontains='https://') + ) + return queryset + def filter_min_accepted_contributions(self, queryset, name, value): """Exclude submissions from users with less than N accepted contributions.""" if value and value > 0: diff --git a/frontend/src/components/StewardSearchBar.svelte b/frontend/src/components/StewardSearchBar.svelte new file mode 100644 index 00000000..e8a6cb09 --- /dev/null +++ b/frontend/src/components/StewardSearchBar.svelte @@ -0,0 +1,409 @@ + + +
+
+ + + + + +
+ + {#if showAutocomplete && suggestions.length > 0} +
+ {#each suggestions as suggestion, index} + + {/each} +
+ {/if} + + {#if showHelp} +
+
Search Syntax
+
+
+
type:blog-postFilter by contribution type
+
from:usernameSearch by user name/email/address
+
assigned:meFilter by assignment (me, unassigned, name)
+
exclude:medium.comExclude submissions containing text
+
include:genlayerOnly show submissions containing text
+
has:urlOnly submissions with URLs
+
no:urlOnly submissions without URLs
+
min-contributions:3Users with N+ accepted contributions
+
sort:-createdSort order (created, -created, date, -date)
+
+
+
Negation
+
-type:blog-postExclude contribution type
+
+
+
Examples
+
assigned:me exclude:medium.com has:url
+
from:alice -type:referral min-contributions:2
+
+
All terms must use tags. Unrecognized text is ignored.
+
+
+ {/if} +
+ + diff --git a/frontend/src/lib/searchParser.js b/frontend/src/lib/searchParser.js new file mode 100644 index 00000000..16760c2d --- /dev/null +++ b/frontend/src/lib/searchParser.js @@ -0,0 +1,192 @@ +/** + * Parse a GitHub-style search query into structured filters. + * + * Supported tags: + * - status:pending|accepted|rejected|more_info + * - type:contribution-type-name + * - from:username + * - assigned:me|unassigned|steward-name + * - exclude:text (multiple allowed) + * - include:text (multiple allowed) + * - has:url|evidence + * - no:url|evidence + * - min-contributions:number + * - sort:created|-created|date|-date + * + * Negation: -tag:value or NOT tag:value + * Quoted values: tag:"value with spaces" + */ + +const SINGLE_VALUE_TAGS = ['status', 'type', 'from', 'assigned', 'sort']; +const MULTI_VALUE_TAGS = ['exclude', 'include', 'has', 'no']; +const NUMERIC_TAGS = ['min-contributions']; + +/** + * Tokenize the search query, respecting quoted strings. + * @param {string} query + * @returns {string[]} + */ +function tokenize(query) { + const tokens = []; + let current = ''; + let inQuotes = false; + let quoteChar = ''; + + for (let i = 0; i < query.length; i++) { + const char = query[i]; + + if ((char === '"' || char === "'") && !inQuotes) { + inQuotes = true; + quoteChar = char; + } else if (char === quoteChar && inQuotes) { + inQuotes = false; + quoteChar = ''; + } else if (char === ' ' && !inQuotes) { + if (current.trim()) { + tokens.push(current.trim()); + } + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + tokens.push(current.trim()); + } + + return tokens; +} + +/** + * Parse a single token into tag, value, and negation flag. + * @param {string} token + * @returns {{ tag: string, value: string, negated: boolean } | null} + */ +function parseToken(token) { + let negated = false; + let workingToken = token; + + // Check for negation prefix + if (workingToken.startsWith('-')) { + negated = true; + workingToken = workingToken.slice(1); + } else if (workingToken.toUpperCase().startsWith('NOT ')) { + negated = true; + workingToken = workingToken.slice(4); + } + + // Check for tag:value pattern + const colonIndex = workingToken.indexOf(':'); + if (colonIndex === -1) { + return null; // Not a valid tag + } + + const tag = workingToken.slice(0, colonIndex).toLowerCase(); + let value = workingToken.slice(colonIndex + 1); + + // Remove surrounding quotes from value + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + if (!value) { + return null; // Empty value + } + + return { tag, value, negated }; +} + +/** + * Parse a search query string into structured filters. + * @param {string} query + * @returns {Object} + */ +export function parseSearch(query) { + const filters = { + status: null, + type: null, + from: null, + assigned: null, + exclude: [], + include: [], + has: [], + no: [], + minContributions: null, + sort: null + }; + + if (!query || !query.trim()) { + return { filters }; + } + + const tokens = tokenize(query); + + for (const token of tokens) { + // Handle "NOT tag:value" as two tokens + if (token.toUpperCase() === 'NOT') { + continue; // Will be handled with next token + } + + const parsed = parseToken(token); + if (!parsed) { + continue; // Ignore unrecognized tokens + } + + const { tag, value, negated } = parsed; + + // Handle single-value tags + if (SINGLE_VALUE_TAGS.includes(tag)) { + filters[tag] = { value, negated }; + } + // Handle multi-value tags + else if (MULTI_VALUE_TAGS.includes(tag)) { + filters[tag].push(value); + } + // Handle numeric tags + else if (NUMERIC_TAGS.includes(tag)) { + const num = parseInt(value, 10); + if (!isNaN(num)) { + if (tag === 'min-contributions') { + filters.minContributions = num; + } + } + } + } + + return { filters }; +} + +/** + * Convert parsed filters back to a query string. + * Useful for URL sync and display. + * @param {Object} filters + * @returns {string} + */ +export function filtersToQuery(filters) { + const parts = []; + + for (const tag of SINGLE_VALUE_TAGS) { + const filter = filters[tag]; + if (filter && filter.value) { + const prefix = filter.negated ? '-' : ''; + const value = filter.value.includes(' ') ? `"${filter.value}"` : filter.value; + parts.push(`${prefix}${tag}:${value}`); + } + } + + for (const tag of MULTI_VALUE_TAGS) { + const values = filters[tag] || []; + for (const value of values) { + const formattedValue = value.includes(' ') ? `"${value}"` : value; + parts.push(`${tag}:${formattedValue}`); + } + } + + if (filters.minContributions !== null && filters.minContributions > 0) { + parts.push(`min-contributions:${filters.minContributions}`); + } + + return parts.join(' '); +} diff --git a/frontend/src/lib/searchToParams.js b/frontend/src/lib/searchToParams.js new file mode 100644 index 00000000..5e1bab61 --- /dev/null +++ b/frontend/src/lib/searchToParams.js @@ -0,0 +1,119 @@ +/** + * Convert parsed search filters to API query parameters. + */ + +/** + * Map parsed search filters to API parameters. + * @param {Object} parsed - Output from parseSearch() + * @param {Object} options - Additional context + * @param {Array} options.contributionTypes - List of contribution types for name/slug lookup + * @param {Array} options.stewardsList - List of stewards for name lookup + * @param {string} options.currentUserId - Current user's ID for "me" resolution + * @returns {Object} API query parameters + */ +export function searchToParams(parsed, options = {}) { + const { contributionTypes = [], stewardsList = [], currentUserId = null } = options; + const params = {}; + + const { filters } = parsed; + + // status → state (handle negation as exclude_state) + if (filters.status) { + if (filters.status.negated) { + params.exclude_state = filters.status.value; + } else { + params.state = filters.status.value; + } + } + + // type → contribution_type (by name, slug, or ID) + if (filters.type) { + const typeValue = filters.type.value.toLowerCase(); + const type = contributionTypes.find(t => + t.slug?.toLowerCase() === typeValue || + t.name?.toLowerCase() === typeValue || + String(t.id) === filters.type.value + ); + if (type) { + if (filters.type.negated) { + params.exclude_contribution_type = type.id; + } else { + params.contribution_type = type.id; + } + } + } + + // from → username_search (or exclude_username if negated) + if (filters.from) { + if (filters.from.negated) { + params.exclude_username = filters.from.value; + } else { + params.username_search = filters.from.value; + } + } + + // assigned → assigned_to + if (filters.assigned) { + const val = filters.assigned.value.toLowerCase(); + let assignedValue = null; + + if (val === 'me' && currentUserId) { + assignedValue = currentUserId; + } else if (val === 'unassigned' || val === 'none') { + assignedValue = 'unassigned'; + } else { + // Find steward by name + const steward = stewardsList.find(s => + s.name?.toLowerCase().includes(val) || + s.user_name?.toLowerCase().includes(val) + ); + if (steward) { + assignedValue = steward.user_id; + } + } + + if (assignedValue) { + if (filters.assigned.negated) { + params.exclude_assigned_to = assignedValue; + } else { + params.assigned_to = assignedValue; + } + } + } + + // exclude → exclude_content (comma-separated for multiple values) + if (filters.exclude && filters.exclude.length > 0) { + params.exclude_content = filters.exclude.join(','); + } + + // include → include_content (comma-separated for multiple values) + if (filters.include && filters.include.length > 0) { + params.include_content = filters.include.join(','); + } + + // has:url / no:url → evidence filters + if (filters.no && (filters.no.includes('url') || filters.no.includes('evidence'))) { + params.only_empty_evidence = true; + } else if (filters.has && (filters.has.includes('url') || filters.has.includes('evidence'))) { + params.exclude_empty_evidence = true; + } + + // min-contributions + if (filters.minContributions !== null && filters.minContributions > 0) { + params.min_accepted_contributions = filters.minContributions; + } + + // sort → ordering + if (filters.sort) { + const sortMap = { + 'created': 'created_at', + '-created': '-created_at', + 'date': 'contribution_date', + '-date': '-contribution_date' + }; + const sortValue = filters.sort.value; + params.ordering = sortMap[sortValue] || sortValue; + } + + return params; +} diff --git a/frontend/src/routes/StewardSubmissions.svelte b/frontend/src/routes/StewardSubmissions.svelte index 7ca235b9..d0ff4758 100644 --- a/frontend/src/routes/StewardSubmissions.svelte +++ b/frontend/src/routes/StewardSubmissions.svelte @@ -1,13 +1,15 @@