mirror of
https://github.com/bugsink/bugsink.git
synced 2025-12-23 13:59:46 -06:00
887 lines
38 KiB
Python
887 lines
38 KiB
Python
import math
|
|
from collections import namedtuple
|
|
import json
|
|
import sentry_sdk
|
|
import logging
|
|
|
|
from datetime import timedelta
|
|
from django.db.models import Q
|
|
from django.utils import timezone
|
|
from django.shortcuts import render, get_object_or_404, redirect
|
|
from django.http import HttpResponseRedirect, HttpResponseNotAllowed
|
|
from django.urls import reverse
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.http import Http404
|
|
from django.core.paginator import Paginator, Page
|
|
from django.db.utils import OperationalError
|
|
from django.conf import settings
|
|
from django.utils.functional import cached_property
|
|
|
|
from sentry.utils.safe import get_path
|
|
from sentry_sdk_extensions import capture_or_log_exception
|
|
|
|
from bugsink.decorators import project_membership_required, issue_membership_required, atomic_for_request_method
|
|
from bugsink.transaction import durable_atomic
|
|
from bugsink.period_utils import add_periods_to_datetime
|
|
from bugsink.timed_sqlite_backend.base import different_runtime_limit
|
|
from bugsink.utils import assert_
|
|
|
|
from events.models import Event
|
|
from events.ua_stuff import get_contexts_enriched_with_ua
|
|
|
|
from compat.timestamp import format_timestamp
|
|
from projects.models import ProjectMembership
|
|
from tags.search import search_issues, search_events, search_events_optimized
|
|
from theme.templatetags.issues import timestamp_with_millis
|
|
|
|
from .models import Issue, IssueQuerysetStateManager, IssueStateManager, TurningPoint, TurningPointKind
|
|
from .forms import CommentForm
|
|
from .utils import get_values, get_main_exception
|
|
from events.utils import annotate_with_meta, apply_sourcemaps, get_sourcemap_images
|
|
from events.sparklines import get_event_sparkline_indexscan, get_x_labels, get_y_labels
|
|
|
|
logger = logging.getLogger("bugsink.issues")
|
|
|
|
|
|
MuteOption = namedtuple("MuteOption", ["for_or_until", "period_name", "nr_of_periods", "gte_threshold"])
|
|
|
|
# I imagine that we may make this configurable at the installation, organization and/or project level, but for now we
|
|
# just have a global constant.
|
|
GLOBAL_MUTE_OPTIONS = [
|
|
MuteOption("for", "day", 1, None),
|
|
MuteOption("for", "week", 1, None),
|
|
MuteOption("for", "month", 1, None),
|
|
MuteOption("for", "month", 3, None),
|
|
|
|
MuteOption("until", "hour", 1, 5),
|
|
MuteOption("until", "hour", 24, 5),
|
|
MuteOption("until", "hour", 24, 100),
|
|
]
|
|
|
|
|
|
class EagerPaginator(Paginator):
|
|
# Eager meaning non-lazy; this is a paginator that doesn't postpone the query (implicit in object_list) until the
|
|
# last moment (i.e. when the page is actually rendered). Prompted by the following unhappy combination of facts:
|
|
# * failure in evaluation in the object_list (in my case interrupt, but since this is DB-related: could be anything)
|
|
# * usage of sentry_sdk (when dogfooding)
|
|
# * sentry_sdk's serializer sees a Sequence (Page is a subclass of that) and proceeds accordingly ("fancily")
|
|
# * evaluating the qs and putting it in a list (caching) is in Page.__getitem__ only
|
|
# together, this means that sentry_sdk's serializer will again try to evaulate the QS, right after (and because),
|
|
# this failed, in an attempt to serialize local vars. When that happens: again. Etc.
|
|
#
|
|
# I'm blaming Django, btw: if you implement Sequence, don't do database stuff to get elements.
|
|
#
|
|
# On the now-removed lazyness: when you generate a page, you're going to display it, so just do that right away.
|
|
|
|
def _get_page(self, *args, **kwargs):
|
|
object_list = args[0]
|
|
object_list = list(object_list)
|
|
return Page(object_list, *(args[1:]), **kwargs)
|
|
|
|
|
|
class KnownCountPaginator(EagerPaginator):
|
|
"""optimization: we know the total count of the queryset, so we can avoid a count() query"""
|
|
# see also: bugsink/api_pagination.py for an alternative approach
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
self._count = kwargs.pop("count")
|
|
super().__init__(*args, **kwargs)
|
|
|
|
@property
|
|
def count(self):
|
|
return self._count
|
|
|
|
|
|
class UncountablePage(Page):
|
|
"""The Page subclass to be used with UncountablePaginator."""
|
|
|
|
@cached_property
|
|
def has_next(self):
|
|
# hack that works 249/250 times: if the current page is full, we have a next page
|
|
return len(self.object_list) == self.paginator.per_page
|
|
|
|
@cached_property
|
|
def end_index(self):
|
|
return (self.paginator.per_page * (self.number - 1)) + len(self.object_list)
|
|
|
|
|
|
class UncountablePaginator(EagerPaginator):
|
|
"""optimization: counting is too expensive; to be used in a template w/o .count and .last"""
|
|
# see also: bugsink/api_pagination.py for an alternative approach
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def _get_page(self, *args, **kwargs):
|
|
object_list = args[0]
|
|
object_list = list(object_list)
|
|
return UncountablePage(object_list, *(args[1:]), **kwargs)
|
|
|
|
@property
|
|
def count(self):
|
|
return 1_000_000_000 # big enough to be bigger than what you can click through or store in the DB.
|
|
|
|
|
|
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."""
|
|
|
|
if action == "delete":
|
|
# any type of issue can be deleted
|
|
return True
|
|
|
|
if issue.is_resolved:
|
|
# any action is illegal on resolved issues (as per our current UI)
|
|
return False
|
|
|
|
if action.startswith("resolved_release:"):
|
|
release_version = action.split(":", 1)[1]
|
|
if release_version + "\n" in issue.events_at:
|
|
return False
|
|
|
|
elif action.startswith("mute"):
|
|
if issue.is_muted:
|
|
return False
|
|
|
|
# TODO muting with a VBC that is already met should be invalid. See 'Exception("The unmute condition is already'
|
|
|
|
elif action == "unmute":
|
|
if not issue.is_muted:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _q_for_invalid_for_action(action):
|
|
"""returns a Q obj of issues for which the action is not valid."""
|
|
|
|
if action == "delete":
|
|
# delete is always valid, so we don't want any issues to be returned, https://stackoverflow.com/a/39001190
|
|
return Q(pk__in=[])
|
|
|
|
illegal_conditions = Q(is_resolved=True) # any action is illegal on resolved issues (as per our current UI)
|
|
|
|
if action.startswith("resolved_release:"):
|
|
release_version = action.split(":", 1)[1]
|
|
illegal_conditions = illegal_conditions | Q(events_at__contains=release_version + "\n")
|
|
|
|
elif action.startswith("mute"):
|
|
illegal_conditions = illegal_conditions | Q(is_muted=True)
|
|
|
|
elif action == "unmute":
|
|
illegal_conditions = illegal_conditions | Q(is_muted=False)
|
|
|
|
return illegal_conditions
|
|
|
|
|
|
def _make_history(issue_or_qs, action, user):
|
|
if action == "delete":
|
|
return # we're about to delete the issue, so no history is needed (nor possible)
|
|
|
|
elif action == "resolve":
|
|
kind = TurningPointKind.RESOLVED
|
|
elif action.startswith("resolved"):
|
|
kind = TurningPointKind.RESOLVED
|
|
elif action.startswith("mute"):
|
|
kind = TurningPointKind.MUTED
|
|
elif action == "unmute":
|
|
kind = TurningPointKind.UNMUTED
|
|
else:
|
|
raise ValueError(f"unknown action: {action}")
|
|
|
|
if action.startswith("mute_for:"):
|
|
mute_for_params = action.split(":", 1)[1]
|
|
period_name, nr_of_periods, _ = mute_for_params.split(",")
|
|
unmute_after = add_periods_to_datetime(timezone.now(), int(nr_of_periods), period_name)
|
|
metadata = {"mute_for": {
|
|
"period_name": period_name, "nr_of_periods": int(nr_of_periods),
|
|
"unmute_after": format_timestamp(unmute_after)}}
|
|
|
|
elif action.startswith("mute_until:"):
|
|
mute_for_params = action.split(":", 1)[1]
|
|
period_name, nr_of_periods, gte_threshold = mute_for_params.split(",")
|
|
metadata = {"mute_until": {
|
|
"period_name": period_name, "nr_of_periods": int(nr_of_periods), "gte_threshold": gte_threshold}}
|
|
|
|
elif action == "mute":
|
|
metadata = {"mute_unconditionally": True}
|
|
|
|
elif action.startswith("resolved_release:"):
|
|
release_version = action.split(":", 1)[1]
|
|
metadata = {"resolved_release": release_version}
|
|
elif action == "resolved_next":
|
|
metadata = {"resolve_by_next": True}
|
|
elif action == "resolve":
|
|
metadata = {"resolved_unconditionally": True}
|
|
else:
|
|
metadata = {}
|
|
|
|
now = timezone.now()
|
|
if isinstance(issue_or_qs, Issue):
|
|
TurningPoint.objects.create(
|
|
project=issue_or_qs.project,
|
|
issue=issue_or_qs, kind=kind, user=user, metadata=json.dumps(metadata), timestamp=now)
|
|
else:
|
|
TurningPoint.objects.bulk_create([
|
|
TurningPoint(
|
|
project_id=issue.project_id, issue=issue, kind=kind, user=user, metadata=json.dumps(metadata),
|
|
timestamp=now)
|
|
for issue in issue_or_qs
|
|
])
|
|
|
|
|
|
def _apply_action(manager, issue_or_qs, action, user):
|
|
_make_history(issue_or_qs, action, user)
|
|
|
|
if action == "resolve":
|
|
manager.resolve(issue_or_qs)
|
|
elif action.startswith("resolved_release:"):
|
|
release_version = action.split(":", 1)[1]
|
|
manager.resolve_by_release(issue_or_qs, release_version)
|
|
elif action == "resolved_next":
|
|
manager.resolve_by_next(issue_or_qs)
|
|
# elif action == "reopen": # not allowed from the UI
|
|
# manager.reopen(issue_or_qs)
|
|
elif action == "mute":
|
|
manager.mute(issue_or_qs)
|
|
elif action.startswith("mute_for:"):
|
|
mute_for_params = action.split(":", 1)[1]
|
|
period_name, nr_of_periods, _ = mute_for_params.split(",")
|
|
unmute_after = add_periods_to_datetime(timezone.now(), int(nr_of_periods), period_name)
|
|
manager.mute(issue_or_qs, unmute_after=unmute_after)
|
|
|
|
elif action.startswith("mute_until:"):
|
|
mute_for_params = action.split(":", 1)[1]
|
|
period_name, nr_of_periods, gte_threshold = mute_for_params.split(",")
|
|
|
|
manager.mute(issue_or_qs, unmute_on_volume_based_conditions=json.dumps([{
|
|
"period": period_name,
|
|
"nr_of_periods": int(nr_of_periods),
|
|
"volume": int(gte_threshold),
|
|
}]))
|
|
elif action == "unmute":
|
|
manager.unmute(issue_or_qs)
|
|
elif action == "delete":
|
|
manager.delete(issue_or_qs)
|
|
|
|
|
|
def issue_list(request, project_pk, state_filter="open"):
|
|
# to keep the write lock as short as possible, issue_list is split up into 2 parts (read/write vs pure reading),
|
|
# which take in the order of 5ms / 120ms respectively. Some info is passed between transactions (project and
|
|
# unapplied_issue_ids), but since this is respectively sensitive to much change and the direct result of our own
|
|
# current action, I don't think this can lead to surprising results.
|
|
|
|
project, unapplied_issue_ids = _issue_list_pt_1(request, project_pk=project_pk, state_filter=state_filter)
|
|
with durable_atomic():
|
|
return _issue_list_pt_2(request, project, state_filter, unapplied_issue_ids)
|
|
|
|
|
|
@atomic_for_request_method
|
|
@project_membership_required
|
|
def _issue_list_pt_1(request, project, state_filter="open"):
|
|
if request.method == "POST":
|
|
issue_ids = request.POST.getlist('issue_ids[]')
|
|
issue_qs = Issue.objects.filter(pk__in=issue_ids)
|
|
illegal_conditions = _q_for_invalid_for_action(request.POST["action"])
|
|
# list() is necessary because we need to evaluate the qs before any actions are actually applied (if we don't,
|
|
# actions are always marked as illegal, because they are applied first, then checked (and applying twice is
|
|
# illegal)
|
|
unapplied_issue_ids = list(issue_qs.filter(illegal_conditions).values_list("id", flat=True))
|
|
_apply_action(
|
|
IssueQuerysetStateManager, issue_qs.exclude(illegal_conditions), request.POST["action"], request.user)
|
|
|
|
else:
|
|
unapplied_issue_ids = None
|
|
|
|
return project, unapplied_issue_ids
|
|
|
|
|
|
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),
|
|
"unresolved": lambda qs: qs.filter(is_resolved=False),
|
|
"resolved": lambda qs: qs.filter(is_resolved=True),
|
|
"muted": lambda qs: qs.filter(is_muted=True),
|
|
"all": lambda qs: qs,
|
|
}
|
|
|
|
issue_list = d_state_filter[state_filter](
|
|
Issue.objects.filter(project=project, is_deleted=False)
|
|
).order_by("-last_seen")
|
|
|
|
if request.GET.get("q"):
|
|
issue_list = search_issues(project, issue_list, request.GET["q"])
|
|
|
|
paginator = UncountablePaginator(issue_list, 250)
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
try:
|
|
member = ProjectMembership.objects.get(project=project, user=request.user)
|
|
except ProjectMembership.DoesNotExist:
|
|
member = None # this can happen if the user is superuser (as per `project_membership_required` decorator)
|
|
|
|
return render(request, "issues/issue_list.html", {
|
|
"project": project,
|
|
"member": member,
|
|
"state_filter": state_filter,
|
|
"mute_options": GLOBAL_MUTE_OPTIONS,
|
|
|
|
"unapplied_issue_ids": unapplied_issue_ids,
|
|
|
|
# design decision: we statically determine some disabledness (i.e. choices that will never make sense are
|
|
# disallowed), but we don't have any dynamic disabling based on the selected issues.
|
|
"disable_resolve_buttons": state_filter in ("resolved"),
|
|
"disable_mute_buttons": state_filter in ("resolved", "muted"),
|
|
"disable_unmute_buttons": state_filter in ("resolved", "open"),
|
|
"q": request.GET.get("q", ""),
|
|
"page_obj": page_obj,
|
|
})
|
|
|
|
|
|
def event_by_internal_id(request, event_pk):
|
|
# a view that allows to link straight to an event by (internal) id. This comes with the cost of a bunch more queries
|
|
# and a Http redirect when actually clicked, but has the advantage of not needing that event's issue id when
|
|
# rendering the link. Note that no Auth is needed here because nothing is actually shown.
|
|
event = get_object_or_404(Event, id=event_pk)
|
|
issue = event.issue
|
|
return redirect(issue_event_stacktrace, issue_pk=issue.pk, event_pk=event.pk)
|
|
|
|
|
|
def _handle_post(request, issue):
|
|
if _is_valid_action(request.POST["action"], issue):
|
|
_apply_action(IssueStateManager, issue, request.POST["action"], request.user)
|
|
issue.save()
|
|
|
|
# note that if the action is not valid, we just ignore it (i.e. we don't show any error message or anything)
|
|
# this is probably what you want, because the most common case of action-not-valid is 'it already happened
|
|
# through some other UI path'. The only case I can think of where this is not the case is where you try to
|
|
# resolve an issue for a specific release, and while you where thinking about that, it occurred for that
|
|
# release. In that case it will probably stand out that your buttons don't become greyed out, and that the
|
|
# dropdown no longer functions. already-true-vbc-unmute may be another exception to this rule.
|
|
return HttpResponseRedirect(request.path)
|
|
|
|
|
|
def _get_event(qs, issue, event_pk, digest_order, nav, bounds):
|
|
"""
|
|
Returns the event using the "url lookup".
|
|
The passed qs is "something you can use to deduce digest_order (for next/prev)."
|
|
When a direct (non-nav) method is used, we do _not_ check against existence in qs; this is more performant, and it's
|
|
not clear that being pedantic in this case is actually more valuable from a UX perspective.
|
|
"""
|
|
|
|
if nav is not None:
|
|
if nav not in ["first", "last", "prev", "next"]:
|
|
raise Http404("Cannot look up with '%s'" % nav)
|
|
|
|
if nav == "first":
|
|
# basically, the below. But because first/last are calculated anyway for "_has_next_prev", we pass these
|
|
# digest_order = qs.order_by("digest_order").values_list("digest_order", flat=True).first()
|
|
digest_order = bounds[0]
|
|
elif nav == "last":
|
|
# digest_order = qs.order_by("digest_order").values_list("digest_order", flat=True).last()
|
|
digest_order = bounds[1]
|
|
elif nav in ["prev", "next"]:
|
|
if digest_order is None:
|
|
raise Http404("Cannot look up with '%s' without digest_order" % nav)
|
|
|
|
if nav == "prev":
|
|
digest_order = qs.filter(digest_order__lt=digest_order).values_list("digest_order", flat=True)\
|
|
.order_by("-digest_order").first()
|
|
elif nav == "next":
|
|
digest_order = qs.filter(digest_order__gt=digest_order).values_list("digest_order", flat=True)\
|
|
.order_by("digest_order").first()
|
|
|
|
if digest_order is None:
|
|
raise Event.DoesNotExist
|
|
return Event.objects.get(issue=issue, digest_order=digest_order)
|
|
|
|
elif event_pk is not None:
|
|
# we match on both internal and external id, trying internal first
|
|
try:
|
|
return Event.objects.get(pk=event_pk)
|
|
except Event.DoesNotExist:
|
|
# we match on external id "for user ergonomics" (presumed); i.e. in the (unchecked) presumption that people
|
|
# may sometimes copy/paste the SDK-generated UUID for whatever reason straight into the URL bar. However:
|
|
# [1] the below is not guaranteed unique and [2] it's not indexed as such so may be slow (project_id is a
|
|
# prefix in the index) so YMMV.
|
|
return Event.objects.get(event_id=event_pk)
|
|
|
|
elif digest_order is not None:
|
|
return Event.objects.get(digest_order=digest_order)
|
|
else:
|
|
raise Http404("Either event_pk, nav, or digest_order must be provided")
|
|
|
|
|
|
def _event_count(request, issue, event_x_qs):
|
|
# We want to be able to show the number of matching events for some query in the UI, but counting is potentially
|
|
# expensive, because it's a full scan over all matching events. We just show "many" if this takes too long.
|
|
# different_runtime_limit is sqlite-only, it doesn't affect other backends. (interrupting-and-ignoring works for
|
|
# SELECT; if we instead used '''INSERT, UPDATE, or DELETE [..] inside an explicit transaction [..] the entire
|
|
# [..] transaction will be rolled back automatically.''' https://www.sqlite.org/c3ref/interrupt.html
|
|
|
|
with different_runtime_limit(0.1):
|
|
try:
|
|
return event_x_qs.count() if request.GET.get("q") else issue.stored_event_count
|
|
except OperationalError as e:
|
|
if e.args[0] != "interrupted":
|
|
raise
|
|
return "many"
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def issue_event_stacktrace(request, issue, event_pk=None, digest_order=None, nav=None):
|
|
if request.method == "POST":
|
|
return _handle_post(request, issue)
|
|
|
|
event_x_qs = search_events_optimized(issue.project, issue, request.GET.get("q", ""))
|
|
first_do, last_do = _first_last(event_x_qs)
|
|
|
|
try:
|
|
event = _get_event(event_x_qs, issue, event_pk, digest_order, nav, (first_do, last_do))
|
|
except Event.DoesNotExist:
|
|
return issue_event_404(request, issue, event_x_qs, "stacktrace", "event_stacktrace")
|
|
|
|
parsed_data = event.get_parsed_data()
|
|
|
|
exceptions = get_values(parsed_data["exception"]) if "exception" in parsed_data else None
|
|
|
|
try:
|
|
# get_values for consistency (whether it's needed: unclear, since _meta is not actually in the specs)
|
|
meta_values = get_values(parsed_data.get("_meta", {}).get("exception", {"values": {}}))
|
|
annotate_with_meta(exceptions, meta_values)
|
|
except Exception as e:
|
|
# broad Exception handling: "_meta" is completely undocumented, and though we have some example of event-data
|
|
# with "_meta" in it, we're not quite sure what the full structure could be in the wild. Because the
|
|
# 'incomplete' annotations are not absolutely necessary (Sentry itself went without it for years) we silently
|
|
# swallow the error in that case.
|
|
sentry_sdk.capture_exception(e)
|
|
|
|
try:
|
|
apply_sourcemaps(parsed_data)
|
|
except Exception as e:
|
|
if settings.DEBUG or settings.I_AM_RUNNING == "TEST":
|
|
# when developing/testing, I _do_ want to get notified
|
|
raise
|
|
|
|
# sourcemaps are still experimental; we don't want to fail on them, so we just log the error and move on.
|
|
capture_or_log_exception(e, logger)
|
|
|
|
# NOTE: I considered making this a clickable button of some sort, but decided against it in the end. Getting the UI
|
|
# right is quite hard (https://ux.stackexchange.com/questions/1318) but more generally I would assume that having
|
|
# your whole screen turned upside down is not something you do willy-nilly. Better to just have good defaults and
|
|
# (possibly later) have this as something that is configurable at the user level.
|
|
stack_of_plates = event.platform != "python" # Python is the only platform that has chronological stacktraces
|
|
|
|
if exceptions is not None and len(exceptions) > 0:
|
|
if exceptions[-1].get('stacktrace') and exceptions[-1]['stacktrace'].get('frames'):
|
|
exceptions[-1]['stacktrace']['frames'][-1]['raise_point'] = True
|
|
|
|
if stack_of_plates:
|
|
# NOTE manipulation of parsed_data going on here, this could be a trap if other parts depend on it
|
|
# (e.g. grouper)
|
|
exceptions = [e for e in reversed(exceptions)]
|
|
for exception in exceptions:
|
|
if not exception.get('stacktrace'):
|
|
continue
|
|
if not exception.get('stacktrace').get('frames'):
|
|
continue
|
|
exception['stacktrace']['frames'] = [f for f in reversed(exception['stacktrace']['frames'])]
|
|
|
|
return render(request, "issues/stacktrace.html", {
|
|
"tab": "stacktrace",
|
|
"this_view": "event_stacktrace",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"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_count is not used when there is no q, so no need to calculate it in that case
|
|
"event_qs_count": _event_count(request, issue, event_x_qs) if request.GET.get("q") else None,
|
|
"has_prev": event.digest_order > first_do,
|
|
"has_next": event.digest_order < last_do,
|
|
})
|
|
|
|
|
|
def issue_event_404(request, issue, event_x_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"""
|
|
|
|
return render(request, "issues/event_404.html", {
|
|
"tab": tab,
|
|
"this_view": this_view,
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"is_event_page": False, # this variable is used to denote "we have event-related info", which we don't
|
|
"mute_options": GLOBAL_MUTE_OPTIONS,
|
|
"q": request.GET.get("q", ""),
|
|
# for the 404 view we always calculate the count (q or no q) because it's used to determine what text to show.
|
|
"event_qs_count": _event_count(request, issue, event_x_qs),
|
|
})
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def issue_event_breadcrumbs(request, issue, event_pk=None, digest_order=None, nav=None):
|
|
if request.method == "POST":
|
|
return _handle_post(request, issue)
|
|
|
|
event_x_qs = search_events_optimized(issue.project, issue, request.GET.get("q", ""))
|
|
first_do, last_do = _first_last(event_x_qs)
|
|
|
|
try:
|
|
event = _get_event(event_x_qs, issue, event_pk, digest_order, nav, (first_do, last_do))
|
|
except Event.DoesNotExist:
|
|
return issue_event_404(request, issue, event_x_qs, "breadcrumbs", "event_breadcrumbs")
|
|
|
|
parsed_data = event.get_parsed_data()
|
|
|
|
return render(request, "issues/breadcrumbs.html", {
|
|
"tab": "breadcrumbs",
|
|
"this_view": "event_breadcrumbs",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"event": event,
|
|
"is_event_page": True,
|
|
"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_count is not used when there is no q, so no need to calculate it in that case
|
|
"event_qs_count": _event_count(request, issue, event_x_qs) if request.GET.get("q") else None,
|
|
"has_prev": event.digest_order > first_do,
|
|
"has_next": event.digest_order < last_do,
|
|
})
|
|
|
|
|
|
def _first_last(qs_with_digest_order):
|
|
# this was once implemented with Min/Max, but just doing 2 queries is (on sqlite at least) much faster.
|
|
first = qs_with_digest_order.order_by("digest_order").values_list("digest_order", flat=True).first()
|
|
last = qs_with_digest_order.order_by("-digest_order").values_list("digest_order", flat=True).first()
|
|
return first, last
|
|
|
|
|
|
@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_x_qs = search_events_optimized(issue.project, issue, request.GET.get("q", ""))
|
|
first_do, last_do = _first_last(event_x_qs)
|
|
|
|
try:
|
|
event = _get_event(event_x_qs, issue, event_pk, digest_order, nav, (first_do, last_do))
|
|
except Event.DoesNotExist:
|
|
return issue_event_404(request, issue, event_x_qs, "event-details", "event_details")
|
|
parsed_data = event.get_parsed_data()
|
|
|
|
key_info = [
|
|
("title", event.title()),
|
|
("transaction", issue.transaction),
|
|
# transaction_info.source avoid information overload; sentry doesn't bother showing this in the UI either
|
|
("event_id", event.event_id),
|
|
("bugsink_internal_id", event.id),
|
|
]
|
|
|
|
if get_path(get_main_exception(parsed_data), "mechanism", "handled") is not None:
|
|
key_info += [
|
|
# grepping on [private-]samples (admittedly: not a very rich set) has shown: when there's multiple values
|
|
# for mechanism, they're always identical. We just pick the 'main' (best guess) if this ever turns out to be
|
|
# false. sentry repeats this info throughout the chains in the trace, btw, but I don't want to pollute my
|
|
# UI so much.
|
|
("handled", get_path(get_main_exception(parsed_data), "mechanism", "handled")),
|
|
]
|
|
|
|
key_info += [
|
|
("mechanism", get_path(get_main_exception(parsed_data), "mechanism", "type")),
|
|
|
|
("issue_id", issue.id),
|
|
("timestamp", timestamp_with_millis(event.timestamp)),
|
|
("ingested at", timestamp_with_millis(event.ingested_at)),
|
|
("digested at", timestamp_with_millis(event.digested_at)),
|
|
("digest order", event.digest_order),
|
|
("remote_addr", event.remote_addr),
|
|
]
|
|
|
|
logentry_info = []
|
|
if parsed_data.get("logger") or parsed_data.get("logentry") or parsed_data.get("message"):
|
|
if "level" in parsed_data:
|
|
# Sentry gives "level" a front row seat in the UI; but we don't: in an Error Tracker, the default is just
|
|
# "error" (and we don't want to pollute the UI with this info). Sentry's documentation is also very sparse
|
|
# on what this actually could be used for, other than that it's "similar" to the log level. I'm just going
|
|
# to interpret that as "it _is_ the log level" and show it in the logentry_info (only).
|
|
# Best source is: https://docs.sentry.dev/platforms/python/usage/set-level/
|
|
logentry_info.append(("level", parsed_data["level"]))
|
|
|
|
if parsed_data.get("logger"):
|
|
logentry_info.append(("logger", parsed_data["logger"]))
|
|
|
|
# "message" is a fallback location for the logentry message. It's not in the specs, but it probably was in the
|
|
# past. see https://github.com/bugsink/bugsink/issues/43
|
|
logentry_key = "logentry" if "logentry" in parsed_data else "message"
|
|
|
|
if isinstance(parsed_data.get(logentry_key), dict):
|
|
# NOTE: event.schema.json says "If `message` and `params` are given, Sentry will attempt to backfill
|
|
# `formatted` if empty." but we don't do that yet.
|
|
if parsed_data.get(logentry_key, {}).get("formatted"):
|
|
logentry_info.append(("formatted", parsed_data[logentry_key]["formatted"]))
|
|
|
|
if parsed_data.get(logentry_key, {}).get("message"):
|
|
logentry_info.append(("message", parsed_data[logentry_key]["message"]))
|
|
|
|
params = parsed_data.get(logentry_key, {}).get("params", {})
|
|
if isinstance(params, list):
|
|
for param_i, param_v in enumerate(params):
|
|
logentry_info.append(("#%s" % param_i, param_v))
|
|
elif isinstance(params, dict):
|
|
for param_k, param_v in params.items():
|
|
logentry_info.append((param_k, param_v))
|
|
|
|
elif isinstance(parsed_data.get(logentry_key), str): # robust for top-level as str (see #55)
|
|
logentry_info.append(("message", parsed_data[logentry_key]))
|
|
|
|
key_info += [
|
|
("grouping key", event.grouping.grouping_key),
|
|
]
|
|
|
|
deployment_info = \
|
|
([("release", parsed_data["release"])] if "release" in parsed_data else []) + \
|
|
([("environment", parsed_data["environment"])] if "environment" in parsed_data else []) + \
|
|
([("server_name", parsed_data["server_name"])] if "server_name" in parsed_data else [])
|
|
|
|
contexts = get_contexts_enriched_with_ua(parsed_data)
|
|
|
|
try:
|
|
sourcemaps_images = get_sourcemap_images(parsed_data)
|
|
except Exception as e:
|
|
if settings.DEBUG or settings.I_AM_RUNNING == "TEST":
|
|
# when developing/testing, I _do_ want to get notified
|
|
raise
|
|
# sourcemaps are still experimental; we don't want to fail on them, so we just log the error and move on.
|
|
capture_or_log_exception(e, logger)
|
|
|
|
start, end, interval = get_buckets_range_input()
|
|
x_labels = get_x_labels(start, end)
|
|
|
|
buckets = get_buckets(start, end, interval, issue.id)
|
|
max_value = max(buckets) or 0
|
|
if max_value == 0:
|
|
bar_data = [0 for v in buckets]
|
|
else:
|
|
bar_data = [(v / max_value) * 100 for v in buckets]
|
|
y_labels = get_y_labels(max_value, 4)
|
|
|
|
return render(request, "issues/event_details.html", {
|
|
"tab": "event-details",
|
|
"this_view": "event_details",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"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,
|
|
"sourcemaps_images": sourcemaps_images,
|
|
"mute_options": GLOBAL_MUTE_OPTIONS,
|
|
"q": request.GET.get("q", ""),
|
|
# event_qs_count is not used when there is no q, so no need to calculate it in that case
|
|
"event_qs_count": _event_count(request, issue, event_x_qs) if request.GET.get("q") else None,
|
|
"has_prev": event.digest_order > first_do,
|
|
"has_next": event.digest_order < last_do,
|
|
"bar_data": bar_data,
|
|
"y_labels": y_labels,
|
|
"x_labels": x_labels,
|
|
})
|
|
|
|
|
|
def get_buckets_range_input():
|
|
# align on 4-hour boundary; round up from now
|
|
|
|
now = timezone.localtime()
|
|
|
|
hour_step = 4
|
|
|
|
# determine how many hours to add to get to the next 4-hour boundary
|
|
fraction = ((now.hour + now.minute / 60 + now.second / 3600) / (24 // hour_step))
|
|
boundary = math.ceil(fraction) * (24 // hour_step)
|
|
end = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(hours=boundary)
|
|
|
|
# today_midnight = now.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(days=1)
|
|
start = end - timedelta(days=28)
|
|
interval = timedelta(hours=hour_step)
|
|
return start, end, interval
|
|
|
|
|
|
def get_buckets(start, end, interval, issue_id):
|
|
data = get_event_sparkline_indexscan(
|
|
start=start,
|
|
end=end,
|
|
interval=interval,
|
|
issue_id=issue_id,
|
|
)
|
|
|
|
return [row["count"] for row in data]
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def issue_history(request, issue):
|
|
if request.method == "POST":
|
|
return _handle_post(request, issue)
|
|
|
|
event_qs = search_events(issue.project, issue, request.GET.get("q", ""))
|
|
last_event = event_qs.order_by("digest_order").last()
|
|
return render(request, "issues/history.html", {
|
|
"tab": "history",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"is_event_page": False,
|
|
"request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
|
|
"mute_options": GLOBAL_MUTE_OPTIONS,
|
|
})
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def issue_tags(request, issue):
|
|
if request.method == "POST":
|
|
return _handle_post(request, issue)
|
|
|
|
event_qs = search_events(issue.project, issue, request.GET.get("q", ""))
|
|
last_event = event_qs.order_by("digest_order").last()
|
|
return render(request, "issues/tags.html", {
|
|
"tab": "tags",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"is_event_page": False,
|
|
"request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
|
|
"mute_options": GLOBAL_MUTE_OPTIONS,
|
|
})
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def issue_grouping(request, issue):
|
|
if request.method == "POST":
|
|
return _handle_post(request, issue)
|
|
|
|
event_qs = search_events(issue.project, issue, request.GET.get("q", ""))
|
|
last_event = event_qs.order_by("digest_order").last()
|
|
return render(request, "issues/grouping.html", {
|
|
"tab": "grouping",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"is_event_page": False,
|
|
"request_repr": _request_repr(last_event.get_parsed_data()) if last_event is not None else "",
|
|
"mute_options": GLOBAL_MUTE_OPTIONS,
|
|
})
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def issue_event_list(request, issue):
|
|
if request.method == "POST":
|
|
return _handle_post(request, issue)
|
|
|
|
# because we we need _actual events_ for display, and we don't have the regular has_prev/has_next (paginator
|
|
# instead), we don't try to optimize using search_events_optimized in this view (except for counting)
|
|
if "q" in request.GET:
|
|
event_list = search_events(issue.project, issue, request.GET["q"]).order_by("digest_order")
|
|
event_x_qs = search_events_optimized(issue.project, issue, request.GET.get("q", ""))
|
|
# we don't do the `_event_count` optimization here, because we need the correct number for pagination
|
|
paginator = KnownCountPaginator(event_list, 250, count=event_x_qs.count())
|
|
else:
|
|
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)
|
|
|
|
page_number = request.GET.get("page")
|
|
page_obj = paginator.get_page(page_number)
|
|
|
|
last_event = event_list.last()
|
|
|
|
return render(request, "issues/event_list.html", {
|
|
"tab": "event-list",
|
|
"project": issue.project,
|
|
"issue": issue,
|
|
"event_list": event_list,
|
|
"is_event_page": False,
|
|
"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,
|
|
})
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def history_comment_new(request, issue):
|
|
|
|
if request.method == "POST":
|
|
form = CommentForm(request.POST)
|
|
assert_(form.is_valid()) # we have only a textfield with no validation properties; also: no html-side handling
|
|
|
|
if form.cleaned_data["comment"] != "":
|
|
# one special case: we simply ignore newly created comments without any contents as a (presumed) mistake. I
|
|
# think that's amount of magic to have: it still allows one to erase comments (possibly for non-manual
|
|
# kinds) but it saves you from what is obviously a mistake (without complaining with a red box or something)
|
|
TurningPoint.objects.create(
|
|
project=issue.project,
|
|
issue=issue, kind=TurningPointKind.MANUAL_ANNOTATION, user=request.user,
|
|
comment=form.cleaned_data["comment"],
|
|
timestamp=timezone.now())
|
|
|
|
return redirect(issue_history, issue_pk=issue.pk)
|
|
|
|
return HttpResponseNotAllowed(["POST"])
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def history_comment_edit(request, issue, comment_pk):
|
|
comment = get_object_or_404(TurningPoint, pk=comment_pk, issue_id=issue.pk)
|
|
|
|
if comment.user_id != request.user.id:
|
|
raise PermissionDenied("You can only edit your own comments")
|
|
|
|
if request.method == "POST":
|
|
form = CommentForm(request.POST, instance=comment)
|
|
assert_(form.is_valid())
|
|
form.save()
|
|
return redirect(reverse(issue_history, kwargs={'issue_pk': issue.pk}) + f"#comment-{ comment_pk }")
|
|
|
|
|
|
@atomic_for_request_method
|
|
@issue_membership_required
|
|
def history_comment_delete(request, issue, comment_pk):
|
|
comment = get_object_or_404(TurningPoint, pk=comment_pk, issue_id=issue.pk)
|
|
|
|
if comment.user_id != request.user.id:
|
|
raise PermissionDenied("You can only delete your own comments")
|
|
|
|
if comment.kind != TurningPointKind.MANUAL_ANNOTATION:
|
|
# I'm taking the 'allow almost nothing' path first
|
|
raise PermissionDenied("You can only delete manual annotations")
|
|
|
|
if request.method == "POST":
|
|
comment.delete()
|
|
return redirect(reverse(issue_history, kwargs={'issue_pk': issue.pk}))
|
|
|
|
return HttpResponseNotAllowed(["POST"])
|