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/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/contributions/serializers.py b/backend/contributions/serializers.py index fecbfdb5..52d92aa2 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 @@ -85,7 +85,7 @@ class ContributionTypeSerializer(serializers.ModelSerializer): class Meta: model = ContributionType fields = [ - 'id', 'name', 'description', 'category', 'min_points', 'max_points', + 'id', 'name', 'slug', 'description', 'category', 'min_points', 'max_points', 'current_multiplier', 'is_submittable', 'examples', 'created_at', 'updated_at' ] @@ -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..5193916b 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 @@ -774,9 +774,15 @@ 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_assigned_to = CharFilter(method='filter_exclude_assigned_to') + exclude_contribution_type = NumberFilter(method='filter_exclude_contribution_type') + 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 +795,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 +813,58 @@ 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_assigned_to(self, queryset, name, value): + """Exclude submissions assigned to a specific steward.""" + if value == 'null' or value == 'unassigned': + return queryset.exclude(assigned_to__isnull=True) + elif value: + return queryset.exclude(assigned_to_id=value) + return queryset + + def filter_exclude_contribution_type(self, queryset, name, value): + """Exclude submissions of a specific contribution type.""" 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(contribution_type_id=value) + return queryset + + def filter_exclude_state(self, queryset, name, value): + """Exclude submissions with specific state.""" + if value: + 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(description__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(description__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 +885,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: @@ -1170,3 +1237,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/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..f52a5e0d 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,14 @@ clear_correlation_id, format_bytes, ) +from .tracing import ( + init_tracing, + clear_tracing, + get_segments, + format_breakdown, + should_expand_trace, + get_external_segments, +) logger = get_api_logger() @@ -20,10 +30,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 +54,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 +74,27 @@ 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)" + # Get trace segments + segments = get_segments() + external_segments = get_external_segments(segments) + + # Determine logging behavior + is_server_error = response.status_code >= 500 + is_slow = should_expand_trace(duration_ms) + has_external = len(external_segments) > 0 + + # Show breakdown for slow requests or errors (only if there's something interesting) + show_breakdown = is_server_error or (settings.DEBUG and is_slow and (has_external or db_query_count > 0)) + + # Build timing info string - use full breakdown for slow/error requests + if show_breakdown: + breakdown = format_breakdown(duration_ms, db_time_ms, db_query_count, segments) + timing_info = f"{duration_ms:.0f}ms ({breakdown})" else: - timing_info = f"{duration_ms:.0f}ms" + 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 +102,19 @@ def __call__(self, request): f"{format_bytes(response_size)}" ) - # Log based on status code and DEBUG setting - is_error = response.status_code >= 400 - - if is_error: - # Always log errors - if response.status_code >= 500: - logger.error(log_message) - else: - logger.warning(log_message) + if is_server_error: + logger.error(log_message) elif settings.DEBUG: - # In debug mode, log all requests - logger.info(log_message) + logger.debug(log_message) - # 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" 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..b70874c3 --- /dev/null +++ b/backend/tally/middleware/tracing.py @@ -0,0 +1,193 @@ +""" +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 get_external_segments(segments: list) -> list: + """ + Get only external segments from the segment list. + + Args: + segments: List of segment dicts from get_segments() + + Returns: + List of external segment dicts (aggregated by name) + """ + # Aggregate external segments with the same name + aggregated = {} + for segment in segments: + if not segment.get('is_external', False): + continue + 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'], + 'count': 1, + } + + # Sort by duration descending + return sorted(aggregated.values(), key=lambda s: s['duration_ms'], reverse=True) + + +def format_breakdown( + total_ms: float, + db_time_ms: float, + db_query_count: int, + segments: list, +) -> str: + """ + Format a timing breakdown as a single-line string. + + Shows: + - db: Database query time (with query count) + - ext:*: External API calls (if any) + - app: Application code time (serialization, view logic, etc.) + + Args: + total_ms: Total request duration in milliseconds + db_time_ms: Database query time in milliseconds + db_query_count: Number of database queries + segments: List of segment dicts from get_segments() + + Returns: + Single-line string with timing breakdown, e.g.: + "db: 317ms/45q, app: 1243ms" or + "db: 50ms/5q, ext:github:check_star: 780ms, app: 20ms" + """ + external_segments = get_external_segments(segments) + + # Calculate total external time + total_external_ms = sum(s['duration_ms'] for s in external_segments) + + # Calculate app time (everything not db or external) + app_time_ms = max(0, total_ms - db_time_ms - total_external_ms) + + # Build the breakdown parts + parts = [] + + # Add db time first + if db_query_count > 0: + parts.append(f"db: {db_time_ms:.0f}ms/{db_query_count}q") + + # Add external segments + for segment in external_segments: + name = segment['name'] + duration = segment['duration_ms'] + count = segment['count'] + count_suffix = f" x{count}" if count > 1 else "" + parts.append(f"{name}: {duration:.0f}ms{count_suffix}") + + # Add app time + parts.append(f"app: {app_time_ms:.0f}ms") + + return ", ".join(parts) + + +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], 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)} + + +
+ Startup ideas we believe could thrive in the GenLayer ecosystem.
+ The GenLayer team is ready to support founders by opening doors and providing guidance.
+
+ {request.short_description} +
+type:blog-postFilter by contribution typefrom:usernameSearch by user name/email/addressassigned:meFilter by assignment (me, unassigned, name)exclude:medium.comExclude submissions containing textinclude:genlayerOnly show submissions containing texthas:urlOnly submissions with URLsno:urlOnly submissions without URLsmin-contributions:3Users with N+ accepted contributionssort:-createdSort order (created, -created, date, -date)-type:blog-postExclude contribution type{error}
+ +{startupRequest.short_description}
+ {/if} +