Skip to content

Commit f7bb3ca

Browse files
committed
feat: sentry stats
Use sentry stats api to detect event. For now those event are just created and do not modify the smaple rate
1 parent caae145 commit f7bb3ca

32 files changed

+1807
-115
lines changed

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,11 @@ repos:
2222
- id: pylama
2323
args: # arguments to configure black
2424
- --max-line-length=119
25-
# to remove when this is fixed https://github.com/klen/pylama/issues/224
26-
additional_dependencies:
27-
- pyflakes==2.4.0
25+
# due to https://github.com/PyCQA/pycodestyle/issues/373
26+
- -i E203
2827

2928
- repo: https://github.com/pycqa/isort
30-
rev: 5.10.1
29+
rev: 5.12.0
3130
hooks:
3231
- id: isort
3332
args: ["-m=VERTICAL_HANGING_INDENT", "--combine-as", "--profile=black"]
@@ -65,7 +64,8 @@ repos:
6564
rev: "1.7.4"
6665
hooks:
6766
- id: bandit
68-
files: "^orange/.*"
67+
files: "^controller/.*"
68+
exclude: "tests/.*"
6969

7070
- repo: https://github.com/remastr/pre-commit-django-check-migrations
7171
rev: v0.1.0

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ python manage.py migrate
3232
# add user
3333
python manage.py createsuperuser
3434

35+
# load all permission group
36+
python manage.py loadpermissions
37+
3538
# run server
3639
# admin @ http://localhost:8000/admin/
3740
python manage.py runserver

controller/sentry/admin.py

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,81 @@
1010
from django.core.cache import cache
1111
from django.db import models
1212
from django.utils import timezone
13+
from django.utils.html import format_html
1314
from django_better_admin_arrayfield.admin.mixins import DynamicArrayMixin
1415
from django_json_widget.widgets import JSONEditorWidget
1516
from django_object_actions import DjangoObjectActions, takes_instance_or_queryset
1617

17-
from controller.sentry.choices import MetricType
18+
from controller.sentry.choices import EventType, MetricType
1819
from controller.sentry.forms import BumpForm, MetricForm
19-
from controller.sentry.models import App
20+
from controller.sentry.inlines import AppEventInline, ProjectEventInline
21+
from controller.sentry.mixins import PrettyTypeMixin, ProjectLinkMixin
22+
from controller.sentry.models import App, Event, Project
2023
from controller.sentry.utils import invalidate_cache
2124

2225

26+
@admin.register(Project)
27+
class ProjectAdmin(
28+
AdminConfirmMixin,
29+
ActionFormMixin,
30+
DjangoObjectActions,
31+
DynamicArrayMixin,
32+
admin.ModelAdmin,
33+
):
34+
35+
list_display = [
36+
"sentry_id",
37+
"sentry_project_slug",
38+
]
39+
40+
search_fields = ["sentry_id", "sentry_project_slug"]
41+
ordering = search_fields
42+
43+
formfield_overrides = {
44+
models.JSONField: {"widget": JSONEditorWidget},
45+
}
46+
47+
fieldsets = [
48+
[
49+
None,
50+
{"fields": ("sentry_id", "sentry_project_slug", "detection_param")},
51+
]
52+
]
53+
54+
inlines = [ProjectEventInline]
55+
56+
57+
@admin.register(Event)
58+
class EventAdmin(
59+
ProjectLinkMixin,
60+
AdminConfirmMixin,
61+
ActionFormMixin,
62+
DjangoObjectActions,
63+
DynamicArrayMixin,
64+
PrettyTypeMixin,
65+
admin.ModelAdmin,
66+
):
67+
68+
list_display = ["reference", "pretty_type", "timestamp", "get_project"]
69+
70+
search_fields = ["reference", "type", "project__sentry_project_slug"]
71+
ordering = search_fields
72+
73+
formfield_overrides = {
74+
models.JSONField: {"widget": JSONEditorWidget},
75+
}
76+
77+
fieldsets = [
78+
[
79+
None,
80+
{"fields": ("reference", "type", "project", "timestamp")},
81+
]
82+
]
83+
84+
2385
@admin.register(App)
2486
class AppAdmin(
87+
ProjectLinkMixin,
2588
AdminConfirmMixin,
2689
ActionFormMixin,
2790
DjangoObjectActions,
@@ -32,7 +95,8 @@ class AppAdmin(
3295

3396
list_display = [
3497
"reference",
35-
"sentry_project_slug",
98+
"get_event_status",
99+
"get_project",
36100
"last_seen",
37101
"default_sample_rate",
38102
"active_sample_rate",
@@ -41,9 +105,7 @@ class AppAdmin(
41105
"celery_collect_metrics",
42106
]
43107

44-
search_fields = [
45-
"reference",
46-
]
108+
search_fields = ["reference", "project__sentry_project_slug", "env", "command"]
47109
ordering = search_fields
48110

49111
formfield_overrides = {
@@ -65,7 +127,7 @@ class AppAdmin(
65127
"Sentry",
66128
{
67129
"classes": ("collapse",),
68-
"fields": ("sentry_project_slug",),
130+
"fields": ("project", "env", "command"),
69131
},
70132
],
71133
[
@@ -95,6 +157,17 @@ class AppAdmin(
95157
changelist_actions = ["panic", "unpanic"]
96158
change_actions = ["bump_sample_rate", "enable_disable_metrics"]
97159

160+
inlines = [AppEventInline]
161+
162+
@admin.display(description="Spamming Sentry")
163+
def get_event_status(self, obj):
164+
text = '<b style="color:{};">{}</b>'
165+
if obj.project and (event := obj.project.events.last()):
166+
if event.type == EventType.DISCARD:
167+
return format_html(text, "green", "No")
168+
return format_html(text, "red", "Yes")
169+
return format_html(text, "gray", "Pending")
170+
98171
def get_changelist_actions(self, request):
99172
allowed_actions = []
100173
for action in self.changelist_actions:

controller/sentry/choices.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,8 @@
55
class MetricType(TextChoices):
66
WSGI = "WSGI", _("WSGI")
77
CELERY = "CELERY", _("CELERY")
8+
9+
10+
class EventType(TextChoices):
11+
FIRING = "FIRING", _("firing")
12+
DISCARD = "DISCARD", _("discard")

controller/sentry/detector.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from collections import OrderedDict
2+
from copy import copy
3+
from statistics import mean, stdev
4+
5+
from controller.sentry.models import Project
6+
7+
8+
class SpikesDetector:
9+
def __init__(self, lag=48, threshold=5, influence=0) -> None:
10+
"""
11+
Z-score based algorithm
12+
"""
13+
14+
# The lag parameter determines how much your data will be smoothed and how adaptive the
15+
# algorithm is to changes in the long-term average of the data. The more stationary your
16+
# data is, the more lags you should include (this should improve the robustness of the algorithm).
17+
# If your data contains time-varying trends, you should consider how quickly you want the algorithm
18+
# to adapt to these trends. I.e., if you put lag at 10, it takes 10 'periods' before the algorithm's
19+
# threshold is adjusted to any systematic changes in the long-term average. So choose the lag parameter
20+
# based on the trending behavior of your data and how adaptive you want the algorithm to be.
21+
self.lag = lag
22+
# The threshold parameter is the number of standard deviations from the moving mean above which the
23+
# algorithm will classify a new datapoint as being a signal. For example, if a new datapoint is 4.0
24+
# standard deviations above the moving mean and the threshold parameter is set as 3.5,
25+
# the algorithm will identify the datapoint as a signal. This parameter should be set based
26+
# on how many signals you expect.
27+
# For example, if your data is normally distributed, a threshold (or: z-score) of 3.5
28+
# corresponds to a signaling probability of 0.00047 (from this table),
29+
# which implies that you expect a signal once every 2128 datapoints (1/0.00047).
30+
# The threshold therefore directly influences how sensitive the algorithm is
31+
# and thereby also determines how often the algorithm signals.
32+
self.threshold = threshold
33+
# The influence parameter determines the influence of signals on the algorithm's detection threshold.
34+
# If put at 0, signals have no influence on the threshold, such that future signals are detected based
35+
# on a threshold that is calculated with a mean and standard deviation that is not influenced by past signals.
36+
# If put at 0.5, signals have half the influence of normal data points. Another way to think about this
37+
# is that if you put the influence at 0, you implicitly assume stationarity
38+
# (i.e. no matter how many signals there are, you always expect the time series to return to the
39+
# same average over the long term).
40+
# If this is not the case, you should put the influence parameter somewhere between 0 and 1,
41+
# depending on the extent to which signals can systematically influence the time-varying trend of the data.
42+
# E.g., if signals lead to a structural break of the long-term average of the time series,
43+
# the influence parameter should be put high (close to 1)
44+
# so the threshold can react to structural breaks quickly.
45+
self.influence = influence
46+
47+
@classmethod
48+
def from_project(cls, project: Project):
49+
return cls(**project.detection_param)
50+
51+
def compute_sentry(self, stats):
52+
series = next(
53+
(group["series"]["sum(quantity)"] for group in stats["groups"] if group["by"]["outcome"] == "accepted"),
54+
None,
55+
)
56+
if series is None:
57+
raise ValueError("No series with accepted outcome")
58+
59+
signal, _, _ = self.compute(series)
60+
61+
annotated_result = OrderedDict((date, signal) for date, signal in zip(stats["intervals"], signal))
62+
return annotated_result
63+
64+
def compute(self, data):
65+
signals = [0] * self.lag
66+
avg_filter = [0] * self.lag
67+
std_filter = [0] * self.lag
68+
filtered_data = copy(data)
69+
avg_filter[self.lag - 1] = mean(data[: self.lag])
70+
std_filter[self.lag - 1] = stdev(data[: self.lag])
71+
72+
for i, item in enumerate(data[self.lag :], start=self.lag):
73+
if abs(item - avg_filter[i - 1]) > self.threshold * std_filter[i - 1]:
74+
signals.append(1 if item > avg_filter[i - 1] else 0)
75+
filtered_data[i] = self.influence * item + (1 - self.influence) * filtered_data[i - 1]
76+
else:
77+
signals.append(0)
78+
filtered_data[i] = data[i]
79+
avg_filter.append(mean(filtered_data[(i - self.lag) : i]))
80+
std_filter.append(stdev(filtered_data[(i - self.lag) : i]))
81+
82+
return signals, avg_filter, std_filter

controller/sentry/inlines.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from django.contrib import admin
2+
from nonrelated_inlines.admin import NonrelatedTabularInline
3+
4+
from controller.sentry.mixins import PrettyTypeMixin
5+
from controller.sentry.models import Event
6+
7+
8+
class EventInlineMixin(PrettyTypeMixin):
9+
model = Event
10+
fields = ("reference", "pretty_type", "timestamp", "project")
11+
readonly_fields = fields
12+
can_delete = False
13+
extra = 0
14+
show_change_link = True
15+
16+
ordering = ("-timestamp",)
17+
18+
def has_add_permission(self, request, obj=None) -> bool: # pylint: disable=unused-argument
19+
return False
20+
21+
22+
class ProjectEventInline(EventInlineMixin, admin.TabularInline):
23+
pass
24+
25+
26+
class AppEventInline(EventInlineMixin, NonrelatedTabularInline):
27+
def get_form_queryset(self, obj):
28+
if obj.project is None:
29+
return Event.objects.none()
30+
return obj.project.events.all()
31+
32+
def save_new_instance(self, parent, instance): # pylint: disable=unused-argument
33+
raise NotImplementedError

controller/sentry/management/__init__.py

Whitespace-only changes.

controller/sentry/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from itertools import chain
2+
3+
from django.conf import settings
4+
from django.contrib.auth.models import Group, Permission
5+
from django.core.management.base import BaseCommand
6+
7+
8+
class Command(BaseCommand):
9+
help = "Set default permissions." # noqa: A003
10+
11+
def handle(self, *args, **options):
12+
owner, _ = Group.objects.get_or_create(name="Owner")
13+
admin, _ = Group.objects.get_or_create(name="Admin")
14+
developer, _ = Group.objects.get_or_create(name=settings.DEVELOPER_GROUP)
15+
viewer, _ = Group.objects.get_or_create(name="Viewer")
16+
17+
owner.permissions.set(Permission.objects.all())
18+
admin.permissions.set(Permission.objects.filter(content_type__app_label="sentry"))
19+
20+
view_permission = Permission.objects.filter(codename__contains="view_", content_type__app_label="sentry")
21+
22+
developer.permissions.set(
23+
chain(view_permission.all(), Permission.objects.filter(codename__in=settings.DEVELOPER_ACTIONS))
24+
)
25+
26+
viewer.permissions.set(view_permission.all())

controller/sentry/migrations/0002_app_wsgi_ignore_path_alter_app_reference.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
import django_better_admin_arrayfield.models.fields
44
from django.db import migrations, models
55

6-
import controller.sentry.models
7-
86

97
class Migration(migrations.Migration):
108

@@ -19,7 +17,6 @@ class Migration(migrations.Migration):
1917
field=django_better_admin_arrayfield.models.fields.ArrayField(
2018
base_field=models.CharField(blank=True, max_length=50),
2119
blank=True,
22-
default=controller.sentry.models.get_default_wsgi_ignore_path,
2320
size=None,
2421
),
2522
),

0 commit comments

Comments
 (0)