diff --git a/events/models.py b/events/models.py
index d7e89d3..a80323c 100644
--- a/events/models.py
+++ b/events/models.py
@@ -4,7 +4,6 @@ import uuid
from django.db import models
from django.db.utils import IntegrityError
-from django.db.models import Min, Max
from django.utils.functional import cached_property
from projects.models import Project
@@ -276,18 +275,6 @@ class Event(models.Model):
return None, False
- def get_digest_order_bounds(self):
- if not hasattr(self, "_digest_order_bounds"):
- d = Event.objects.filter(issue_id=self.issue.id).aggregate(lo=Min("digest_order"), hi=Max("digest_order"))
- self._digest_order_bounds = d["lo"], d["hi"]
- return self._digest_order_bounds
-
- def has_prev(self):
- return self.digest_order > self.get_digest_order_bounds()[0]
-
- def has_next(self):
- return self.digest_order < self.get_digest_order_bounds()[1]
-
@cached_property
def get_tags(self):
return list(
diff --git a/issues/templates/issues/_event_nav.html b/issues/templates/issues/_event_nav.html
index e3500ca..92e78d8 100644
--- a/issues/templates/issues/_event_nav.html
+++ b/issues/templates/issues/_event_nav.html
@@ -1,5 +1,11 @@
- {% if event.has_prev %} {# no need for 'is_first': if you can go to the left, you can go all the way to the left too #}
-
+{% load add_to_qs %}
+
+
+
+ {% if has_prev %} {# no need for 'is_first': if you can go to the left, you can go all the way to the left too #}
+
{% else %}
@@ -8,8 +14,8 @@
{% endif %}
- {% if event.has_prev %}
-
+ {% if has_prev %}
+
{% else %}
@@ -18,8 +24,8 @@
{% endif %}
- {% if event.has_next %}
-
+ {% if has_next %}
+
{% else %}
@@ -28,8 +34,8 @@
{% endif %}
- {% if event.has_next %}
-
+ {% if has_next %}
+
{% else %}
diff --git a/issues/templates/issues/base.html b/issues/templates/issues/base.html
index 21a2508..d3e503c 100644
--- a/issues/templates/issues/base.html
+++ b/issues/templates/issues/base.html
@@ -3,6 +3,7 @@
{% load issues %}
{% load humanize %}
{% load stricter_templates %}
+{% load add_to_qs %}
{% block title %}{{ issue.title }} · {{ block.super }}{% endblock %}
{% block content %}
@@ -95,7 +96,7 @@
{% if issue.last_frame_module %}{{ issue.last_frame_module}}{% else %}{{ issue.last_frame_filename }}{% endif %}{% if issue.last_frame_function %} in {{ issue.last_frame_function }}{% endif %}
{# top, LHS (various texts) #}
@@ -106,13 +107,13 @@
{# overflow-x-auto is needed at the level of the flex item such that it works at the level where we need it (the code listings)#}
{# 96rem is 1536px, which matches the 2xl class; this is no "must" but eyeballing revealed: good result #}
xxxx xx xx xx:xx (Event xxx of {{ issue.digested_event_count }})
+
{{ issue.digested_event_count|intcomma }} events in total{% if q %} — {{ event_qs_count }} found by search{% endif %}.
- {# copy/paste of _event_nav, but not based on any event (we have none), prev/next are meaningless also #}
- {# so we have first/last enabled, and the middle ones disabled #}
+ {# copy/paste of _event_nav, but not based on any event (we have none), prev/next are meaningless also; first/last only when we have an event_qs to navigate through #}
+
-
+ {% if event_qs_count %}
+
+ {% else %}
+
{% if event_qs_count %}404: Event missing from Bugsink{% else %}No Events{% endif %}
+ {# We apply the heuristic (textually) that if you have some events in your event_qs, but no current event, you're in a "404-like" ("This event not found") state #}
+ {# and if there's really no events in the qs, that that fact is what you should focus on. #}
+ {# This works well in practice (better than trying to match these texts to "where in _get_event() did we go wrong?" #}
+ {% if event_qs_count %}
This event cannot be found. It could have been removed manually or as part of the eviction process.
+ {% elif q %}
+ No events found for this search.
+ {% else %}
+ No events found. They could have been removed manually or as part of the eviction process.
+ {% endif %}
- Showing {{ page_obj.start_index|intcomma }} - {{ page_obj.end_index|intcomma }} of {{ page_obj.paginator.count|intcomma }}
- {% if issue.digested_event_count != issue.stored_event_count %}
- available events ({{ issue.digested_event_count|intcomma }} total observed).
- {% else %}
- total events.
+ Showing {{ page_obj.start_index|intcomma }} - {{ page_obj.end_index|intcomma }} of
+ {% if page_obj.paginator.count == issue.stored_event_count and issue.stored_event_count == issue.digested_event_count %} {# all equal #}
+ {{ page_obj.paginator.count|intcomma }} total events.
+ {% elif page_obj.paginator.count == issue.stored_event_count and issue.stored_event_count != issue.digested_event_count %} {# evictions applied #}
+ {{ page_obj.paginator.count|intcomma }} available events ({{ issue.digested_event_count|intcomma }} total observed).
+ {% elif page_obj.paginator.count != issue.stored_event_count and issue.stored_event_count == issue.digested_event_count %} {# search filters #}
+ {{ page_obj.paginator.count|intcomma }} events found ({{ issue.digested_event_count|intcomma }} total observed).
+ {% else %} {# everything unequal #}
+ {{ page_obj.paginator.count|intcomma }} events found ({{ issue.digested_event_count|intcomma }} available, {{ issue.digested_event_count|intcomma }} total observed).
{% endif %}
@@ -20,8 +26,12 @@
{# UI / UX question: is it a good idea to reuse-with-different-meaning (pages, not events) for this? #}
{# adapted copy/pasta from _event_nav #}
+
+
{% if page_obj.has_previous %} {# no need for 'is_first': if you can go to the left, you can go all the way to the left too #}
-
+
{% else %}
+ No events found{% if q %} for "{{ q }}"{% endif %}.
+
+
{% endfor %}
{# note: no "empty" case; event-less issues are not something I expect to really support (for some definition of "really") #}
diff --git a/issues/templates/issues/stacktrace.html b/issues/templates/issues/stacktrace.html
index c4bb7c6..f218b10 100644
--- a/issues/templates/issues/stacktrace.html
+++ b/issues/templates/issues/stacktrace.html
@@ -10,7 +10,7 @@
{# event-nav only #}
-
{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }})
+
{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs.count }} found by search{% endif %})
@@ -31,7 +31,7 @@
{% if forloop.counter0 == 0 %}
-
{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }})
+
{{ event.ingested_at|date:"j M G:i T" }} (Event {{ event.digest_order|intcomma }} of {{ issue.digested_event_count|intcomma }} total{% if q %} — {{ event_qs.count }} found by search{% endif %})
{% endif %}
{{ exception.type }}
{{ exception.value }}
diff --git a/issues/urls.py b/issues/urls.py
index 2d8a93f..773b247 100644
--- a/issues/urls.py
+++ b/issues/urls.py
@@ -22,6 +22,7 @@ def regex_converter(passed_regex):
register_converter(regex_converter("(first|last)"), "first-last")
register_converter(regex_converter("(prev|next)"), "prev-next")
+register_converter(regex_converter("(none)"), "str-none")
urlpatterns = [
@@ -35,6 +36,11 @@ urlpatterns = [
path('issue//event//details/', issue_event_details, name="event_details"),
path('issue//event//breadcrumbs/', issue_event_breadcrumbs, name="event_breadcrumbs"),
+ path('issue//event//', issue_event_stacktrace, name="event_stacktrace"),
+ path('issue//event//details/', issue_event_details, name="event_details"),
+ path('issue//event//breadcrumbs/',
+ issue_event_breadcrumbs, name="event_breadcrumbs"),
+
path('issue//event//', issue_event_stacktrace, name="event_stacktrace"),
path('issue//event//details/', issue_event_details, name="event_details"),
path('issue//event//breadcrumbs/', issue_event_breadcrumbs,
diff --git a/issues/views.py b/issues/views.py
index 7f69b2b..af1944b 100644
--- a/issues/views.py
+++ b/issues/views.py
@@ -3,6 +3,7 @@ import json
import sentry_sdk
import re
+from django.db.models import Min, Max
from django.utils import timezone
from django.shortcuts import render, get_object_or_404, redirect
from django.db.models import Q, Subquery
@@ -26,7 +27,7 @@ from events.ua_stuff import get_contexts_enriched_with_ua
from compat.timestamp import format_timestamp
from projects.models import ProjectMembership
-from tags.models import TagValue, IssueTag
+from tags.models import TagValue, IssueTag, EventTag
from .models import Issue, IssueQuerysetStateManager, IssueStateManager, TurningPoint, TurningPointKind
from .forms import CommentForm
@@ -62,6 +63,13 @@ class KnownCountPaginator(Paginator):
return self._count
+def _request_repr(parsed_data):
+ if "request" not in parsed_data:
+ return ""
+
+ return parsed_data["request"].get("method", "") + " " + parsed_data["request"].get("url", "")
+
+
def _is_valid_action(action, issue):
"""We take the 'strict' approach of complaining even when the action is simply a no-op, because you're already in
the desired state."""
@@ -240,6 +248,9 @@ def _and_join(q_objects):
def _search(project, issue_list, q):
+ if not q:
+ return issue_list
+
# The simplest possible query-language that could have any value: key:value is recognized as such; the rest is "free
# text"; no support for quoting of spaces.
slices_to_remove = []
@@ -274,6 +285,44 @@ def _search(project, issue_list, q):
return issue_list
+def _search_events(project, event_list, q):
+ if not q:
+ return event_list
+
+ # The simplest possible query-language that could have any value: key:value is recognized as such; the rest is "free
+ # text"; no support for quoting of spaces.
+ slices_to_remove = []
+ clauses = []
+ for match in re.finditer(r"(\S+):(\S+)", q):
+ slices_to_remove.append(match.span())
+ key, value = match.groups()
+ try:
+ tag_value_obj = TagValue.objects.get(project=project, key__key=key, value=value)
+ except TagValue.DoesNotExist:
+ # if the tag doesn't exist, we can't have any events with it; the below short-circuit is fine, I think (I
+ # mean: we _could_ say "tag x is to blame" but that's not what one does generally in search, is it?
+ return event_list.none()
+
+ # TODO: Extensive performance testing of various choices here is necessary; in particular the choice of Subquery
+ # vs. joins; and the choice of a separate query to get TagValue v.s. doing everything in a single big query will
+ # have different trade-offs _in practice_.
+ clauses.append(
+ Q(id__in=Subquery(EventTag.objects.filter(value=tag_value_obj).values_list("event_id", flat=True))))
+
+ # this is really TSTTCPW (or more like a "fake it till you make it" thing); but I'd rather "have something" and then
+ # have really-good-search than to have either nothing at all, or half-baked search. Note that we didn't even bother
+ # to set indexes on the fields we search on (nor create a single searchable field for the whole of 'title').
+ plain_text_q = remove_slices(q, slices_to_remove).strip()
+ if plain_text_q:
+ clauses.append(Q(Q(calculated_type__icontains=plain_text_q) | Q(calculated_value__icontains=plain_text_q)))
+
+ # if we reach this point, there's always either a plain_text_q or some key/value pair (this is a condition for
+ # _and_join)
+ event_list = event_list.filter(_and_join(clauses))
+
+ return event_list
+
+
def _issue_list_pt_2(request, project, state_filter, unapplied_issue_ids):
d_state_filter = {
"open": lambda qs: qs.filter(is_resolved=False, is_muted=False),
@@ -336,38 +385,42 @@ def _handle_post(request, issue):
return HttpResponseRedirect(request.path_info)
-def _get_event(issue, event_pk, digest_order, nav):
- if nav is not None:
- if nav == "first":
- return Event.objects.filter(issue=issue).order_by("digest_order").first()
- if nav == "last":
- return Event.objects.filter(issue=issue).order_by("digest_order").last()
+def _get_event(qs, event_pk, digest_order, nav):
+ """Returns the event using the "url lookup"."""
- if nav in ["prev", "next"]:
+ if nav is not None:
+ if nav not in ["first", "last", "prev", "next"]:
+ raise Http404("Cannot look up with '%s'" % nav)
+
+ if nav == "first":
+ result = qs.order_by("digest_order").first()
+ elif nav == "last":
+ result = qs.order_by("digest_order").last()
+ elif nav in ["prev", "next"]:
if digest_order is None:
raise Http404("Cannot look up with '%s' without digest_order" % nav)
if nav == "prev":
- result = Event.objects.filter(
- issue=issue, digest_order__lt=digest_order).order_by("-digest_order").first()
+ result = qs.filter(digest_order__lt=digest_order).order_by("-digest_order").first()
elif nav == "next":
- result = Event.objects.filter(
- issue=issue, digest_order__gt=digest_order).order_by("digest_order").first()
- if result is None:
- raise Event.DoesNotExist
- return result
+ result = qs.filter(digest_order__gt=digest_order).order_by("digest_order").first()
- raise Http404("Cannot look up with '%s'" % nav)
+ if result is None:
+ raise Event.DoesNotExist
+ return result
elif event_pk is not None:
# we match on both internal and external id, trying internal first
+ if event_pk == "none":
+ raise Event.DoesNotExist()
+
try:
return Event.objects.get(pk=event_pk)
except Event.DoesNotExist:
- return Event.objects.get(issue=issue, event_id=event_pk)
+ return qs.get(event_id=event_pk)
elif digest_order is not None:
- return Event.objects.get(issue=issue, digest_order=digest_order)
+ return qs.get(digest_order=digest_order)
else:
raise Http404("Either event_pk, nav, or digest_order must be provided")
@@ -378,10 +431,14 @@ def issue_event_stacktrace(request, issue, event_pk=None, digest_order=None, nav
if request.method == "POST":
return _handle_post(request, issue)
+ event_qs = Event.objects.filter(issue=issue)
+ if request.GET.get("q"):
+ event_qs = _search_events(issue.project, event_qs, request.GET["q"])
+
try:
- event = _get_event(issue, event_pk, digest_order, nav)
+ event = _get_event(event_qs, event_pk, digest_order, nav)
except Event.DoesNotExist:
- return issue_event_404(request, issue, "stacktrace", "event_stacktrace")
+ return issue_event_404(request, issue, event_qs, "stacktrace", "event_stacktrace")
parsed_data = event.get_parsed_data()
@@ -425,16 +482,20 @@ def issue_event_stacktrace(request, issue, event_pk=None, digest_order=None, nav
"event": event,
"is_event_page": True,
"parsed_data": parsed_data,
+ "request_repr": _request_repr(parsed_data),
"exceptions": exceptions,
"stack_of_plates": stack_of_plates,
"mute_options": GLOBAL_MUTE_OPTIONS,
+ "q": request.GET.get("q", ""),
+ "event_qs": event_qs,
+ **_has_next_prev(event, event_qs),
})
-def issue_event_404(request, issue, tab, this_view):
+def issue_event_404(request, issue, event_qs, tab, this_view):
"""If the Event is 404, but the issue is not, we can still show the issue page; we show a message for the event"""
- last_event = issue.event_set.order_by("digest_order").last() # the template needs this for the tabs
+ last_event = event_qs.last() # used for switching to an event-page (using tabs)
return render(request, "issues/event_404.html", {
"tab": tab,
"this_view": this_view,
@@ -443,6 +504,9 @@ def issue_event_404(request, issue, tab, this_view):
"event": last_event,
"is_event_page": False, # this variable is used to denote "we have event-related info", which we don't
"mute_options": GLOBAL_MUTE_OPTIONS,
+ "event_qs": event_qs,
+ "q": request.GET.get("q", ""),
+ "event_qs_count": event_qs.count(), # avoids repeating the count() query
})
@@ -452,10 +516,14 @@ def issue_event_breadcrumbs(request, issue, event_pk=None, digest_order=None, na
if request.method == "POST":
return _handle_post(request, issue)
+ event_qs = Event.objects.filter(issue=issue)
+ if request.GET.get("q"):
+ event_qs = _search_events(issue.project, event_qs, request.GET["q"])
+
try:
- event = _get_event(issue, event_pk, digest_order, nav)
+ event = _get_event(event_qs, event_pk, digest_order, nav)
except Event.DoesNotExist:
- return issue_event_404(request, issue, "breadcrumbs", "event_breadcrumbs")
+ return issue_event_404(request, issue, event_qs, "breadcrumbs", "event_breadcrumbs")
parsed_data = event.get_parsed_data()
@@ -466,9 +534,12 @@ def issue_event_breadcrumbs(request, issue, event_pk=None, digest_order=None, na
"issue": issue,
"event": event,
"is_event_page": True,
- "parsed_data": parsed_data,
+ "request_repr": _request_repr(parsed_data),
"breadcrumbs": get_values(parsed_data.get("breadcrumbs")),
"mute_options": GLOBAL_MUTE_OPTIONS,
+ "q": request.GET.get("q", ""),
+ "event_qs": event_qs,
+ **_has_next_prev(event, event_qs),
})
@@ -478,16 +549,28 @@ def _date_with_milis_html(timestamp):
'' + date(timestamp, "u")[:3] + '')
+def _has_next_prev(event, event_qs):
+ d = event_qs.aggregate(lo=Min("digest_order"), hi=Max("digest_order"))
+ return {
+ "has_prev": event.digest_order > d["lo"] if d.get("lo") is not None else False,
+ "has_next": event.digest_order < d["hi"] if d.get("hi") is not None else False,
+ }
+
+
@atomic_for_request_method
@issue_membership_required
def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=None):
if request.method == "POST":
return _handle_post(request, issue)
+ event_qs = Event.objects.filter(issue=issue)
+ if request.GET.get("q"):
+ event_qs = _search_events(issue.project, event_qs, request.GET["q"])
+
try:
- event = _get_event(issue, event_pk, digest_order, nav)
+ event = _get_event(event_qs, event_pk, digest_order, nav)
except Event.DoesNotExist:
- return issue_event_404(request, issue, "event-details", "event_details")
+ return issue_event_404(request, issue, event_qs, "event-details", "event_details")
parsed_data = event.get_parsed_data()
key_info = [
@@ -557,11 +640,15 @@ def issue_event_details(request, issue, event_pk=None, digest_order=None, nav=No
"event": event,
"is_event_page": True,
"parsed_data": parsed_data,
+ "request_repr": _request_repr(parsed_data),
"key_info": key_info,
"logentry_info": logentry_info,
"deployment_info": deployment_info,
"contexts": contexts,
"mute_options": GLOBAL_MUTE_OPTIONS,
+ "q": request.GET.get("q", ""),
+ "event_qs": event_qs,
+ **_has_next_prev(event, event_qs),
})
@@ -571,14 +658,15 @@ def issue_history(request, issue):
if request.method == "POST":
return _handle_post(request, issue)
- last_event = issue.event_set.order_by("digest_order").last() # the template needs this for the tabs
+ event_qs = _search_events(issue.project, issue.event_set.order_by("digest_order"), request.GET.get("q", ""))
+ last_event = event_qs.last() # used for switching to an event-page (using tabs)
return render(request, "issues/history.html", {
"tab": "history",
"project": issue.project,
"issue": issue,
"event": last_event,
"is_event_page": False,
- "parsed_data": last_event.get_parsed_data(),
+ "request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
"mute_options": GLOBAL_MUTE_OPTIONS,
})
@@ -589,14 +677,15 @@ def issue_tags(request, issue):
if request.method == "POST":
return _handle_post(request, issue)
- last_event = issue.event_set.order_by("digest_order").last() # the template needs this for the tabs
+ event_qs = _search_events(issue.project, issue.event_set.order_by("digest_order"), request.GET.get("q", ""))
+ last_event = event_qs.last() # used for switching to an event-page (using tabs)
return render(request, "issues/tags.html", {
"tab": "tags",
"project": issue.project,
"issue": issue,
"event": last_event,
"is_event_page": False,
- "parsed_data": last_event.get_parsed_data(),
+ "request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
"mute_options": GLOBAL_MUTE_OPTIONS,
})
@@ -607,14 +696,15 @@ def issue_grouping(request, issue):
if request.method == "POST":
return _handle_post(request, issue)
- last_event = issue.event_set.order_by("digest_order").last() # the template needs this for the tabs
+ event_qs = _search_events(issue.project, issue.event_set.order_by("digest_order"), request.GET.get("q", ""))
+ last_event = event_qs.last() # used for switching to an event-page (using tabs)
return render(request, "issues/grouping.html", {
"tab": "grouping",
"project": issue.project,
"issue": issue,
"event": last_event,
"is_event_page": False,
- "parsed_data": last_event.get_parsed_data(),
+ "request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
"mute_options": GLOBAL_MUTE_OPTIONS,
})
@@ -627,13 +717,18 @@ def issue_event_list(request, issue):
event_list = issue.event_set.order_by("digest_order")
- # re 250: in general "big is good" because it allows a lot "at a glance".
- paginator = KnownCountPaginator(event_list, 250, count=issue.stored_event_count)
+ if "q" in request.GET:
+ event_list = _search_events(issue.project, event_list, request.GET["q"])
+ paginator = Paginator(event_list, 250) # might as well use Paginator; the cost of .count() is incurred anyway
+ else:
+ # re 250: in general "big is good" because it allows a lot "at a glance".
+ paginator = KnownCountPaginator(event_list, 250, count=issue.stored_event_count)
page_number = request.GET.get("page")
page_obj = paginator.get_page(page_number)
- last_event = issue.event_set.order_by("digest_order").last() # the template needs this for the tabs
+ last_event = event_list.last() # used for switching to an event-page (using tabs)
+
return render(request, "issues/event_list.html", {
"tab": "event-list",
"project": issue.project,
@@ -641,8 +736,9 @@ def issue_event_list(request, issue):
"event": last_event,
"event_list": event_list,
"is_event_page": False,
- "parsed_data": last_event.get_parsed_data(),
+ "request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
"mute_options": GLOBAL_MUTE_OPTIONS,
+ "q": request.GET.get("q", ""),
"page_obj": page_obj,
})
diff --git a/theme/templatetags/add_to_qs.py b/theme/templatetags/add_to_qs.py
index 7907e84..a1c62f9 100644
--- a/theme/templatetags/add_to_qs.py
+++ b/theme/templatetags/add_to_qs.py
@@ -7,6 +7,8 @@ register = template.Library()
@register.simple_tag(takes_context=True)
def add_to_qs(context, **kwargs):
+ """add kwargs to query string"""
+
if 'request' not in context:
# "should not happen", because this tag is only assumed to be used in RequestContext templates, but it's not
# something I want to break for. Also: we have an answer that "mostly works" for that case, so let's do that.
@@ -15,3 +17,16 @@ def add_to_qs(context, **kwargs):
query = copy(context['request'].GET.dict())
query.update(kwargs)
return urlencode(query)
+
+
+@register.simple_tag(takes_context=True)
+def current_qs(context):
+ if 'request' not in context:
+ # "should not happen", because this tag is only assumed to be used in RequestContext templates, but it's not
+ # something I want to break for. Also: we have an answer that "mostly works" for that case, so let's do that.
+ return ""
+
+ query = copy(context['request'].GET.dict())
+ if query:
+ return '?' + urlencode(query)
+ return ""