|
@@ -10,8 +10,11 @@ from __future__ import absolute_import
|
|
|
import six
|
|
|
import string
|
|
|
import warnings
|
|
|
+import pytz
|
|
|
|
|
|
from collections import OrderedDict
|
|
|
+from datetime import datetime
|
|
|
+from dateutil.parser import parse as parse_date
|
|
|
from django.db import models
|
|
|
from django.utils import timezone
|
|
|
from django.utils.translation import ugettext_lazy as _
|
|
@@ -23,50 +26,18 @@ from sentry.db.models import (
|
|
|
BoundedBigIntegerField,
|
|
|
BoundedIntegerField,
|
|
|
Model,
|
|
|
+ NodeData,
|
|
|
NodeField,
|
|
|
sane_repr
|
|
|
)
|
|
|
from sentry.db.models.manager import EventManager
|
|
|
from sentry.interfaces.base import get_interfaces
|
|
|
-from sentry.utils.cache import memoize
|
|
|
from sentry.utils.canonical import CanonicalKeyDict, CanonicalKeyView
|
|
|
from sentry.utils.safe import get_path
|
|
|
from sentry.utils.strings import truncatechars
|
|
|
|
|
|
|
|
|
-class Event(Model):
|
|
|
- """
|
|
|
- An individual event.
|
|
|
- """
|
|
|
- __core__ = False
|
|
|
-
|
|
|
- group_id = BoundedBigIntegerField(blank=True, null=True)
|
|
|
- event_id = models.CharField(max_length=32, null=True, db_column="message_id")
|
|
|
- project_id = BoundedBigIntegerField(blank=True, null=True)
|
|
|
- message = models.TextField()
|
|
|
- platform = models.CharField(max_length=64, null=True)
|
|
|
- datetime = models.DateTimeField(default=timezone.now, db_index=True)
|
|
|
- time_spent = BoundedIntegerField(null=True)
|
|
|
- data = NodeField(
|
|
|
- blank=True,
|
|
|
- null=True,
|
|
|
- ref_func=lambda x: x.project_id or x.project.id,
|
|
|
- ref_version=2,
|
|
|
- wrapper=CanonicalKeyDict,
|
|
|
- )
|
|
|
-
|
|
|
- objects = EventManager()
|
|
|
-
|
|
|
- class Meta:
|
|
|
- app_label = 'sentry'
|
|
|
- db_table = 'sentry_message'
|
|
|
- verbose_name = _('message')
|
|
|
- verbose_name_plural = _('messages')
|
|
|
- unique_together = (('project_id', 'event_id'), )
|
|
|
- index_together = (('group_id', 'datetime'), )
|
|
|
-
|
|
|
- __repr__ = sane_repr('project_id', 'group_id')
|
|
|
-
|
|
|
+class EventCommon(object):
|
|
|
@classmethod
|
|
|
def generate_node_id(cls, project_id, event_id):
|
|
|
"""
|
|
@@ -77,44 +48,40 @@ class Event(Model):
|
|
|
"""
|
|
|
return md5('{}:{}'.format(project_id, event_id)).hexdigest()
|
|
|
|
|
|
- def __getstate__(self):
|
|
|
- state = Model.__getstate__(self)
|
|
|
-
|
|
|
- # do not pickle cached info. We want to fetch this on demand
|
|
|
- # again. In particular if we were to pickle interfaces we would
|
|
|
- # pickle a CanonicalKeyView which old sentry workers do not know
|
|
|
- # about
|
|
|
- state.pop('_project_cache', None)
|
|
|
- state.pop('_group_cache', None)
|
|
|
- state.pop('interfaces', None)
|
|
|
-
|
|
|
- return state
|
|
|
-
|
|
|
- # Implement a ForeignKey-like accessor for backwards compat
|
|
|
- def _set_group(self, group):
|
|
|
- self.group_id = group.id
|
|
|
- self._group_cache = group
|
|
|
-
|
|
|
- def _get_group(self):
|
|
|
+ # TODO (alex) We need a better way to cache these properties. functools32
|
|
|
+ # doesn't quite do the trick as there is a reference bug with unsaved
|
|
|
+ # models. But the current _group_cache thing is also clunky because these
|
|
|
+ # properties need to be stripped out in __getstate__.
|
|
|
+ @property
|
|
|
+ def group(self):
|
|
|
from sentry.models import Group
|
|
|
if not hasattr(self, '_group_cache'):
|
|
|
self._group_cache = Group.objects.get(id=self.group_id)
|
|
|
return self._group_cache
|
|
|
|
|
|
- group = property(_get_group, _set_group)
|
|
|
-
|
|
|
- # Implement a ForeignKey-like accessor for backwards compat
|
|
|
- def _set_project(self, project):
|
|
|
- self.project_id = project.id
|
|
|
- self._project_cache = project
|
|
|
+ @group.setter
|
|
|
+ def group(self, group):
|
|
|
+ self.group_id = group.id
|
|
|
+ self._group_cache = group
|
|
|
|
|
|
- def _get_project(self):
|
|
|
+ @property
|
|
|
+ def project(self):
|
|
|
from sentry.models import Project
|
|
|
if not hasattr(self, '_project_cache'):
|
|
|
self._project_cache = Project.objects.get(id=self.project_id)
|
|
|
return self._project_cache
|
|
|
|
|
|
- project = property(_get_project, _set_project)
|
|
|
+ @project.setter
|
|
|
+ def project(self, project):
|
|
|
+ self.project_id = project.id
|
|
|
+ self._project_cache = project
|
|
|
+
|
|
|
+ @property
|
|
|
+ def interfaces(self):
|
|
|
+ return CanonicalKeyView(get_interfaces(self.data))
|
|
|
+
|
|
|
+ def get_interface(self, name):
|
|
|
+ return self.interfaces.get(name)
|
|
|
|
|
|
def get_legacy_message(self):
|
|
|
# TODO(mitsuhiko): remove this code once it's unused. It's still
|
|
@@ -195,11 +162,6 @@ class Event(Model):
|
|
|
or get_path(self.data, 'logentry', 'message') \
|
|
|
or ''
|
|
|
|
|
|
- @property
|
|
|
- def message_short(self):
|
|
|
- warnings.warn('Event.message_short is deprecated, use Event.title', DeprecationWarning)
|
|
|
- return self.title
|
|
|
-
|
|
|
@property
|
|
|
def organization(self):
|
|
|
return self.project.organization
|
|
@@ -208,7 +170,7 @@ class Event(Model):
|
|
|
def version(self):
|
|
|
return self.data.get('version', '5')
|
|
|
|
|
|
- @memoize
|
|
|
+ @property
|
|
|
def ip_address(self):
|
|
|
ip_address = get_path(self.data, 'user', 'ip_address')
|
|
|
if ip_address:
|
|
@@ -220,14 +182,8 @@ class Event(Model):
|
|
|
|
|
|
return None
|
|
|
|
|
|
- def get_interfaces(self):
|
|
|
- return CanonicalKeyView(get_interfaces(self.data))
|
|
|
-
|
|
|
- @memoize
|
|
|
- def interfaces(self):
|
|
|
- return self.get_interfaces()
|
|
|
-
|
|
|
- def get_tags(self):
|
|
|
+ @property
|
|
|
+ def tags(self):
|
|
|
try:
|
|
|
rv = sorted([(t, v) for t, v in get_path(
|
|
|
self.data, 'tags', filter=True) or () if t is not None and v is not None])
|
|
@@ -237,7 +193,9 @@ class Event(Model):
|
|
|
# vs ((tag, foo), (tag, bar))
|
|
|
return []
|
|
|
|
|
|
- tags = property(get_tags)
|
|
|
+ # For compatibility, still used by plugins.
|
|
|
+ def get_tags(self):
|
|
|
+ return self.tags
|
|
|
|
|
|
def get_tag(self, key):
|
|
|
for t, v in self.get_tags():
|
|
@@ -257,6 +215,41 @@ class Event(Model):
|
|
|
"""Returns the internal raw event data dict."""
|
|
|
return dict(self.data.items())
|
|
|
|
|
|
+ @property
|
|
|
+ def size(self):
|
|
|
+ data_len = 0
|
|
|
+ for value in six.itervalues(self.data):
|
|
|
+ data_len += len(repr(value))
|
|
|
+ return data_len
|
|
|
+
|
|
|
+ @property
|
|
|
+ def transaction(self):
|
|
|
+ return self.get_tag('transaction')
|
|
|
+
|
|
|
+ def get_email_subject(self):
|
|
|
+ template = self.project.get_option('mail:subject_template')
|
|
|
+ if template:
|
|
|
+ template = EventSubjectTemplate(template)
|
|
|
+ else:
|
|
|
+ template = DEFAULT_SUBJECT_TEMPLATE
|
|
|
+ return truncatechars(
|
|
|
+ template.safe_substitute(
|
|
|
+ EventSubjectTemplateData(self),
|
|
|
+ ),
|
|
|
+ 128,
|
|
|
+ ).encode('utf-8')
|
|
|
+
|
|
|
+ def get_environment(self):
|
|
|
+ from sentry.models import Environment
|
|
|
+
|
|
|
+ if not hasattr(self, '_environment_cache'):
|
|
|
+ self._environment_cache = Environment.objects.get(
|
|
|
+ organization_id=self.project.organization_id,
|
|
|
+ name=Environment.get_name_or_default(self.get_tag('environment')),
|
|
|
+ )
|
|
|
+
|
|
|
+ return self._environment_cache
|
|
|
+
|
|
|
def as_dict(self):
|
|
|
"""Returns the data in normalized form for external consumers."""
|
|
|
# We use a OrderedDict to keep elements ordered for a potential JSON serializer
|
|
@@ -269,7 +262,7 @@ class Event(Model):
|
|
|
data['message'] = self.real_message
|
|
|
data['datetime'] = self.datetime
|
|
|
data['time_spent'] = self.time_spent
|
|
|
- data['tags'] = [(k.split('sentry:', 1)[-1], v) for (k, v) in self.get_tags()]
|
|
|
+ data['tags'] = [(k.split('sentry:', 1)[-1], v) for (k, v) in self.tags]
|
|
|
for k, v in sorted(six.iteritems(self.data)):
|
|
|
if k in data:
|
|
|
continue
|
|
@@ -288,12 +281,9 @@ class Event(Model):
|
|
|
|
|
|
return data
|
|
|
|
|
|
- @property
|
|
|
- def size(self):
|
|
|
- data_len = 0
|
|
|
- for value in six.itervalues(self.data):
|
|
|
- data_len += len(repr(value))
|
|
|
- return data_len
|
|
|
+ # ============================================
|
|
|
+ # DEPRECATED
|
|
|
+ # ============================================
|
|
|
|
|
|
@property
|
|
|
def level(self):
|
|
@@ -320,7 +310,9 @@ class Event(Model):
|
|
|
|
|
|
@property
|
|
|
def server_name(self):
|
|
|
- warnings.warn('Event.server_name is deprecated. Use Event.tags instead.')
|
|
|
+ warnings.warn(
|
|
|
+ 'Event.server_name is deprecated. Use Event.tags instead.',
|
|
|
+ DeprecationWarning)
|
|
|
return self.get_tag('server_name')
|
|
|
|
|
|
@property
|
|
@@ -328,39 +320,220 @@ class Event(Model):
|
|
|
warnings.warn('Event.checksum is no longer used', DeprecationWarning)
|
|
|
return ''
|
|
|
|
|
|
- def error(self):
|
|
|
+ def error(self): # TODO why is this not a property?
|
|
|
warnings.warn('Event.error is deprecated, use Event.title', DeprecationWarning)
|
|
|
return self.title
|
|
|
|
|
|
error.short_description = _('error')
|
|
|
|
|
|
@property
|
|
|
- def transaction(self):
|
|
|
- return self.get_tag('transaction')
|
|
|
+ def message_short(self):
|
|
|
+ warnings.warn('Event.message_short is deprecated, use Event.title', DeprecationWarning)
|
|
|
+ return self.title
|
|
|
|
|
|
- def get_email_subject(self):
|
|
|
- template = self.project.get_option('mail:subject_template')
|
|
|
- if template:
|
|
|
- template = EventSubjectTemplate(template)
|
|
|
- else:
|
|
|
- template = DEFAULT_SUBJECT_TEMPLATE
|
|
|
- return truncatechars(
|
|
|
- template.safe_substitute(
|
|
|
- EventSubjectTemplateData(self),
|
|
|
- ),
|
|
|
- 128,
|
|
|
- ).encode('utf-8')
|
|
|
|
|
|
- def get_environment(self):
|
|
|
- from sentry.models import Environment
|
|
|
+class SnubaEvent(EventCommon):
|
|
|
+ """
|
|
|
+ An event backed by data stored in snuba.
|
|
|
|
|
|
- if not hasattr(self, '_environment_cache'):
|
|
|
- self._environment_cache = Environment.objects.get(
|
|
|
- organization_id=self.project.organization_id,
|
|
|
- name=Environment.get_name_or_default(self.get_tag('environment')),
|
|
|
- )
|
|
|
+ This is a readonly event and does not support event creation or save.
|
|
|
+ The basic event data is fetched from snuba, and the event body is
|
|
|
+ fetched from nodestore and bound to the data property in the same way
|
|
|
+ as a regular Event.
|
|
|
+ """
|
|
|
|
|
|
- return self._environment_cache
|
|
|
+ # The list of columns that we should request from snuba to be able to fill
|
|
|
+ # out the object.
|
|
|
+ selected_columns = [
|
|
|
+ 'event_id',
|
|
|
+ 'project_id',
|
|
|
+ 'message',
|
|
|
+ 'title',
|
|
|
+ 'type',
|
|
|
+ 'location',
|
|
|
+ 'culprit',
|
|
|
+ 'timestamp',
|
|
|
+ 'group_id',
|
|
|
+ 'platform',
|
|
|
+
|
|
|
+ # Required to provide snuba-only tags
|
|
|
+ 'tags.key',
|
|
|
+ 'tags.value',
|
|
|
+
|
|
|
+ # Required to provide snuba-only 'user' interface
|
|
|
+ 'user_id',
|
|
|
+ 'username',
|
|
|
+ 'ip_address',
|
|
|
+ 'email',
|
|
|
+ ]
|
|
|
+
|
|
|
+ __repr__ = sane_repr('project_id', 'group_id')
|
|
|
+
|
|
|
+ @classmethod
|
|
|
+ def get_event(cls, project_id, event_id):
|
|
|
+ from sentry.utils import snuba
|
|
|
+ result = snuba.raw_query(
|
|
|
+ start=datetime.utcfromtimestamp(0), # will be clamped to project retention
|
|
|
+ end=datetime.utcnow(), # will be clamped to project retention
|
|
|
+ selected_columns=cls.selected_columns,
|
|
|
+ filter_keys={
|
|
|
+ 'event_id': [event_id],
|
|
|
+ 'project_id': [project_id],
|
|
|
+ },
|
|
|
+ )
|
|
|
+ if 'error' not in result and len(result['data']) == 1:
|
|
|
+ return SnubaEvent(result['data'][0])
|
|
|
+ return None
|
|
|
+
|
|
|
+ def __init__(self, snuba_values):
|
|
|
+ assert set(snuba_values.keys()) == set(self.selected_columns)
|
|
|
+
|
|
|
+ self.__dict__ = snuba_values
|
|
|
+
|
|
|
+ # This should be lazy loaded and will only be accessed if we access any
|
|
|
+ # properties on self.data
|
|
|
+ node_id = SnubaEvent.generate_node_id(self.project_id, self.event_id)
|
|
|
+ self.data = NodeData(None, node_id, data=None)
|
|
|
+
|
|
|
+ # ============================================
|
|
|
+ # Snuba-only implementations of properties that
|
|
|
+ # would otherwise require nodestore data.
|
|
|
+ # ============================================
|
|
|
+ @property
|
|
|
+ def tags(self):
|
|
|
+ """
|
|
|
+ Override of tags property that uses tags from snuba rather than
|
|
|
+ the nodestore event body. This might be useful for implementing
|
|
|
+ tag deletions without having to rewrite nodestore blobs.
|
|
|
+ """
|
|
|
+ keys = getattr(self, 'tags.key', None)
|
|
|
+ values = getattr(self, 'tags.value', None)
|
|
|
+ if keys and values and len(keys) == len(values):
|
|
|
+ return sorted(zip(keys, values))
|
|
|
+ return []
|
|
|
+
|
|
|
+ def get_interface(self, name):
|
|
|
+ """
|
|
|
+ Override of interface getter that lets us return some interfaces
|
|
|
+ directly from Snuba data.
|
|
|
+ """
|
|
|
+ if name in ['user']:
|
|
|
+ from sentry.interfaces.user import User
|
|
|
+ # This is a fake version of the User interface constructed
|
|
|
+ # from just the data we have in Snuba.
|
|
|
+ snuba_user = {
|
|
|
+ 'id': self.user_id,
|
|
|
+ 'email': self.email,
|
|
|
+ 'username': self.username,
|
|
|
+ 'ip_address': self.ip_address,
|
|
|
+ }
|
|
|
+ if any(v is not None for v in snuba_user.values()):
|
|
|
+ return User.to_python(snuba_user)
|
|
|
+ return self.interfaces.get(name)
|
|
|
+
|
|
|
+ def get_event_type(self):
|
|
|
+ return self.__dict__.get('type', 'default')
|
|
|
+
|
|
|
+ # These should all have been normalized to the correct values on
|
|
|
+ # the way in to snuba, so we should be able to just use them as is.
|
|
|
+ @property
|
|
|
+ def ip_address(self):
|
|
|
+ return self.__dict__['ip_address']
|
|
|
+
|
|
|
+ @property
|
|
|
+ def title(self):
|
|
|
+ return self.__dict__['title']
|
|
|
+
|
|
|
+ @property
|
|
|
+ def culprit(self):
|
|
|
+ return self.__dict__['culprit']
|
|
|
+
|
|
|
+ @property
|
|
|
+ def location(self):
|
|
|
+ return self.__dict__['location']
|
|
|
+
|
|
|
+ # ============================================
|
|
|
+ # Snuba implementations of django Fields
|
|
|
+ # ============================================
|
|
|
+ @property
|
|
|
+ def datetime(self):
|
|
|
+ """
|
|
|
+ Reconstruct the datetime of this event from the snuba timestamp
|
|
|
+ """
|
|
|
+ # dateutil seems to use tzlocal() instead of UTC even though the string
|
|
|
+ # ends with '+00:00', so just replace the TZ with UTC because we know
|
|
|
+ # all timestamps from snuba are UTC.
|
|
|
+ return parse_date(self.timestamp).replace(tzinfo=pytz.utc)
|
|
|
+
|
|
|
+ @property
|
|
|
+ def time_spent(self):
|
|
|
+ return None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def id(self):
|
|
|
+ # Because a snuba event will never have a django row id, just return
|
|
|
+ # the hex event_id here. We should be moving to a world where we never
|
|
|
+ # have to reference the row id anyway.
|
|
|
+ return self.event_id
|
|
|
+
|
|
|
+ @property
|
|
|
+ def next_event(self):
|
|
|
+ return None
|
|
|
+
|
|
|
+ @property
|
|
|
+ def prev_event(self):
|
|
|
+ return None
|
|
|
+
|
|
|
+ def save(self):
|
|
|
+ raise NotImplementedError
|
|
|
+
|
|
|
+
|
|
|
+class Event(EventCommon, Model):
|
|
|
+ """
|
|
|
+ An event backed by data stored in postgres.
|
|
|
+
|
|
|
+ """
|
|
|
+ __core__ = False
|
|
|
+
|
|
|
+ group_id = BoundedBigIntegerField(blank=True, null=True)
|
|
|
+ event_id = models.CharField(max_length=32, null=True, db_column="message_id")
|
|
|
+ project_id = BoundedBigIntegerField(blank=True, null=True)
|
|
|
+ message = models.TextField()
|
|
|
+ platform = models.CharField(max_length=64, null=True)
|
|
|
+ datetime = models.DateTimeField(default=timezone.now, db_index=True)
|
|
|
+ time_spent = BoundedIntegerField(null=True)
|
|
|
+ data = NodeField(
|
|
|
+ blank=True,
|
|
|
+ null=True,
|
|
|
+ ref_func=lambda x: x.project_id or x.project.id,
|
|
|
+ ref_version=2,
|
|
|
+ wrapper=CanonicalKeyDict,
|
|
|
+ )
|
|
|
+
|
|
|
+ objects = EventManager()
|
|
|
+
|
|
|
+ class Meta:
|
|
|
+ app_label = 'sentry'
|
|
|
+ db_table = 'sentry_message'
|
|
|
+ verbose_name = _('message')
|
|
|
+ verbose_name_plural = _('messages')
|
|
|
+ unique_together = (('project_id', 'event_id'), )
|
|
|
+ index_together = (('group_id', 'datetime'), )
|
|
|
+
|
|
|
+ __repr__ = sane_repr('project_id', 'group_id')
|
|
|
+
|
|
|
+ def __getstate__(self):
|
|
|
+ state = Model.__getstate__(self)
|
|
|
+
|
|
|
+ # do not pickle cached info. We want to fetch this on demand
|
|
|
+ # again. In particular if we were to pickle interfaces we would
|
|
|
+ # pickle a CanonicalKeyView which old sentry workers do not know
|
|
|
+ # about
|
|
|
+ state.pop('_project_cache', None)
|
|
|
+ state.pop('_group_cache', None)
|
|
|
+ state.pop('interfaces', None)
|
|
|
+
|
|
|
+ return state
|
|
|
|
|
|
# Find next and previous events based on datetime and id. We cannot
|
|
|
# simply `ORDER BY (datetime, id)` as this is too slow (no index), so
|
|
@@ -375,8 +548,8 @@ class Event(Model):
|
|
|
group_id=self.group_id,
|
|
|
).exclude(id=self.id).order_by('datetime')[0:5]
|
|
|
|
|
|
- events = [e for e in events if e.datetime == self.datetime and e.id > self.id
|
|
|
- or e.datetime > self.datetime]
|
|
|
+ events = [e for e in events if e.datetime == self.datetime and e.id > self.id or
|
|
|
+ e.datetime > self.datetime]
|
|
|
events.sort(key=EVENT_ORDERING_KEY)
|
|
|
return events[0] if events else None
|
|
|
|
|
@@ -387,8 +560,8 @@ class Event(Model):
|
|
|
group_id=self.group_id,
|
|
|
).exclude(id=self.id).order_by('-datetime')[0:5]
|
|
|
|
|
|
- events = [e for e in events if e.datetime == self.datetime and e.id < self.id
|
|
|
- or e.datetime < self.datetime]
|
|
|
+ events = [e for e in events if e.datetime == self.datetime and e.id < self.id or
|
|
|
+ e.datetime < self.datetime]
|
|
|
events.sort(key=EVENT_ORDERING_KEY, reverse=True)
|
|
|
return events[0] if events else None
|
|
|
|