Files
bugsink/issues/models.py
Klaas van Schelven f0e93b4d5d 'Resolved in' actually sends the relevant version 'under water'
if you have an old screen open which says 'resolved in v1.0' that's what
should happen when you click, even when v2.0 has been seen in the meanwhile
2024-02-21 18:55:52 +01:00

222 lines
10 KiB
Python

from datetime import datetime, timezone
import json
import uuid
from django.db import models
from bugsink.volume_based_condition import VolumeBasedCondition
from alerts.tasks import send_unmute_alert
class Issue(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
project = models.ForeignKey(
"projects.Project", blank=False, null=True, on_delete=models.SET_NULL) # SET_NULL: cleanup 'later'
hash = models.CharField(max_length=32, blank=False, null=False)
# denormalized fields:
last_seen = models.DateTimeField(blank=False, null=False)
first_seen = models.DateTimeField(blank=False, null=False)
event_count = models.IntegerField(blank=False, null=False)
# fields related to resolution:
# what does this mean for the release-based use cases? it means what you filter on.
# it also simply means: it was "marked as resolved" after the last regression (if any)
is_resolved = models.BooleanField(default=False)
is_resolved_by_next_release = models.BooleanField(default=False)
fixed_at = models.TextField(blank=False, null=False, default='[]')
events_at = models.TextField(blank=False, null=False, default='[]')
# fields related to muting:
is_muted = models.BooleanField(default=False)
unmute_on_volume_based_conditions = models.TextField(blank=False, null=False, default="[]") # json string
def get_absolute_url(self):
return f"/issues/issue/{ self.id }/event/last/"
def parsed_data(self):
# TEMP solution; won't scale
return json.loads(self.event_set.first().data)
def get_main_exception(self):
# TODO: refactor (its usages) to a (filled-on-create) field
# Note: first event, last exception
# We call the last exception in the chain the main exception because it's the one you're most likely to care
# about. I'd roughly distinguish 2 cases for reraising:
#
# 1. intentionally rephrasing/retyping exceptions to more clearly express their meaning. In that case you
# certainly care more about the rephrased thing than the original, that's the whole point.
#
# 2. actual "accidents" happening while error-handling. In that case you care about the accident first (bugsink
# is a system to help you think about cases that you didn't properly think about in the first place),
# although you may also care about the root cause. (In fact, sometimes you care more about the root cause,
# but I'd say you'll have to yak-shave your way there).
parsed_data = json.loads(self.event_set.first().data)
exc = parsed_data.get("exception", {"values": []})
values = exc["values"] # required by the json spec, so can be done safely
return values[-1] if values else {}
def title(self):
# TODO: refactor to a (filled-on-create) field
main_exception = self.get_main_exception()
return main_exception.get("type", "none") + ": " + main_exception.get("value", "none")
def get_fixed_at(self):
return json.loads(self.fixed_at)
def get_events_at(self):
return json.loads(self.events_at)
def add_fixed_at(self, release_version):
# release_version: str
fixed_at = self.get_fixed_at()
if release_version not in fixed_at:
fixed_at.append(release_version)
self.fixed_at = json.dumps(fixed_at)
def occurs_in_last_release(self):
return False # TODO actually implement (and then: implement in a performant manner)
class Meta:
indexes = [
models.Index(fields=["first_seen"]),
models.Index(fields=["last_seen"]),
]
class IssueStateManager(object):
"""basically: a namespace; with static methods that combine field-setting in a single place"""
# NOTE I'm not so sure about the exact responsibilities of this thingie yet. In particular:
# * save() is now done outside;
# * alerts are sent from inside.
@staticmethod
def resolve(issue):
issue.is_resolved = True
# an issue cannot be both resolved and muted; muted means "the problem persists but don't tell me about it
# (or maybe unless some specific condition happens)" and resolved means "the problem is gone". Hence, resolving
# an issue means unmuting it.
IssueStateManager.unmute(issue, implicitly_called=True)
@staticmethod
def resolve_by_latest(issue):
# NOTE: currently unused; we may soon reintroduce it though so I left it in.
issue.is_resolved = True
issue.add_fixed_at(issue.project.get_latest_release().version)
IssueStateManager.unmute(issue, implicitly_called=True) # as in IssueStateManager.resolve()
@staticmethod
def resolve_by_release(issue, release_version):
# release_version: str
issue.is_resolved = True
issue.add_fixed_at(release_version)
IssueStateManager.unmute(issue, implicitly_called=True) # as in IssueStateManager.resolve()
@staticmethod
def resolve_by_next(issue):
issue.is_resolved = True
issue.is_resolved_by_next_release = True
IssueStateManager.unmute(issue, implicitly_called=True) # as in IssueStateManager.resolve()
@staticmethod
def reopen(issue):
issue.is_resolved = False
issue.is_resolved_by_next_release = False # ?? echt?
# TODO and what about fixed_at ?
# as in IssueStateManager.resolve(), but not because a reopened issue cannot be muted (we could mute it soon
# after reopening) but because when reopening an issue you're doing this from a resolved state; calling unmute()
# here is done as a consistency-enforcement after the fact.
IssueStateManager.unmute(issue, implicitly_called=True)
@staticmethod
def mute(issue, unmute_on_volume_based_conditions="[]"):
from bugsink.registry import get_pc_registry # avoid circular import
now = datetime.now(timezone.utc) # NOTE: clock-reading going on here... should it be passed-in?
issue.is_muted = True
issue.unmute_on_volume_based_conditions = unmute_on_volume_based_conditions
IssueStateManager.set_unmute_handlers(get_pc_registry().by_issue, issue, now)
@staticmethod
def unmute(issue, implicitly_called=False):
# implicitly_called is used to avoid sending an unmute alert when the unmute is triggered by one of the other
# methods in this class.
from bugsink.registry import get_pc_registry, UNMUTE_PURPOSE # avoid circular import
if issue.is_muted:
# we check on is_muted explicitly: it may be so that multiple unmute conditions happens simultaneously (and
# not just in "funny configurations"). i.e. a single event could push you past more than 3 events per day or
# 100 events per year. We don't want 2 "unmuted" alerts being sent in that case.
issue.is_muted = False
issue.unmute_on_volume_based_conditions = "[]"
# We keep the pc_registry and the value of issue.unmute_on_volume_based_conditions in-sync to avoid going
# mad (in general). A specific case that I can think of off the top of my head that goes wrong if you
# wouldn't do this, even given the fact that we check on is_muted in the above: you might re-mute but with
# different unmute conditions, and in that case you don't want your old outdated conditions triggering
# anything. (Side note: I'm not sure how I feel about reaching out to the global registry here; the
# alternative would be to pass this along.)
# (NOTE: upon rereading the above, it seems kind of trivial: a cache should simply be in-sync, no further
# thinking is needed)
get_pc_registry().by_issue[issue.id].remove_event_listener(UNMUTE_PURPOSE)
if not implicitly_called:
# (note: we can expect project to be set, because it will be None only when projects are deleted, in
# which case no more unmuting happens)
if issue.project.alert_on_unmute:
send_unmute_alert.delay(issue.id)
@staticmethod
def set_unmute_handlers(by_issue, issue, now):
from bugsink.registry import UNMUTE_PURPOSE # avoid circular import
issue_pc = by_issue[issue.id]
unmute_vbcs = [
VolumeBasedCondition.from_dict(vbc_s)
for vbc_s in json.loads(issue.unmute_on_volume_based_conditions)
]
for vbc in unmute_vbcs:
initial_state = issue_pc.add_event_listener(
period_name=vbc.period,
nr_of_periods=vbc.nr_of_periods,
gte_threshold=vbc.volume,
when_becomes_true=create_unmute_issue_handler(issue.id),
tup=now.timetuple(),
purpose=UNMUTE_PURPOSE,
)
if initial_state:
# What do you really mean when passing an unmute-condition that is immediately true? Probably: not what
# you asked for (you asked for muting, but provided a condition that would immediately unmute).
#
# We guard for this also because in our implementation, having passed the "become true" point means that
# in fact the condition will only become true _after_ it has become false once. (i.e. the opposite of
# what you'd expect).
#
# Whether to raise an Exception (rather than e.g. validate, or warn, or whatever) is an open question.
# For now we do it to avoid surprises.
#
# One alternative implementation would be: immediately unmute (but that's surprising too!)
# (All of the above applies equally well to at-unmute as it does for load_from_scratch (at which point
# we also just expect unmute conditions to only be set when they can still be triggered)
raise Exception("The unmute condition is already true")
def create_unmute_issue_handler(issue_id):
def unmute():
issue = Issue.objects.get(id=issue_id)
IssueStateManager.unmute(issue)
issue.save()
return unmute