Browse Source

feat: Common functionality for Snuba Events (#11071)

This makes `SnubaEvent` into an almost fully-compatible (read-only) replacement for `Event` objects. `SnubaEvents` pull data from Nodestore if needed, and can be serialized the same way as regular events. But this PR also adds an event serializer that relies only on data from Snuba, this can be used in event search APIs to search and return lists of events quickly.
Alex Hofsteede 6 years ago
parent
commit
39db67e1f9

+ 2 - 3
src/sentry/api/endpoints/group_events.py

@@ -14,11 +14,10 @@ from sentry.api.base import DocSection, EnvironmentMixin
 from sentry.api.bases import GroupEndpoint
 from sentry.api.exceptions import ResourceDoesNotExist
 from sentry.api.helpers.environments import get_environments
-from sentry.api.serializers.models.event import SnubaEvent
 from sentry.api.serializers import serialize
 from sentry.api.paginator import DateTimePaginator, GenericOffsetPaginator
 from sentry.api.utils import get_date_range_from_params
-from sentry.models import Event, Group
+from sentry.models import Event, Group, SnubaEvent
 from sentry.search.utils import (
     InvalidQuery,
     parse_query,
@@ -102,7 +101,7 @@ class GroupEventsEndpoint(GroupEndpoint, EnvironmentMixin):
                 'project_id': [group.project_id],
                 'issue': [group.id]
             },
-            selected_columns=SnubaEvent.selected_columns + ['tags.key', 'tags.value'],
+            selected_columns=SnubaEvent.selected_columns,
             orderby='-timestamp',
             referrer='api.group-events',
         )

+ 4 - 3
src/sentry/api/endpoints/organization_events.py

@@ -8,9 +8,9 @@ from rest_framework.response import Response
 
 from sentry.api.bases import OrganizationEventsEndpointBase, OrganizationEventsError, NoProjects
 from sentry.api.paginator import GenericOffsetPaginator
-from sentry.api.serializers import serialize
-from sentry.api.serializers.models.event import SnubaEvent
+from sentry.api.serializers import serialize, SimpleEventSerializer
 from sentry.api.serializers.snuba import SnubaTSResultSerializer
+from sentry.models import SnubaEvent
 from sentry.utils.dates import parse_stats_period
 from sentry.utils.snuba import raw_query
 from sentry.utils.validators import is_event_id
@@ -63,10 +63,11 @@ class OrganizationEventsEndpoint(OrganizationEventsEndpointBase):
                 **snuba_args
             )
 
+        serializer = SimpleEventSerializer()
         return self.paginate(
             request=request,
             on_results=lambda results: serialize(
-                [SnubaEvent(row) for row in results], request.user),
+                [SnubaEvent(row) for row in results], request.user, serializer),
             paginator=GenericOffsetPaginator(data_fn=data_fn)
         )
 

+ 1 - 1
src/sentry/api/endpoints/project_events.py

@@ -54,7 +54,7 @@ class ProjectEventsEndpoint(ProjectEndpoint):
 
     def _get_events_snuba(self, request, project):
         from sentry.api.paginator import GenericOffsetPaginator
-        from sentry.api.serializers.models.event import SnubaEvent
+        from sentry.models import SnubaEvent
         from sentry.utils.snuba import raw_query
 
         query = request.GET.get('query')

+ 57 - 65
src/sentry/api/serializers/models/event.py

@@ -7,7 +7,14 @@ from django.utils import timezone
 from semaphore import meta_with_chunks
 
 from sentry.api.serializers import Serializer, register, serialize
-from sentry.models import Event, EventError, EventAttachment, Release, UserReport
+from sentry.models import (
+    Event,
+    EventError,
+    EventAttachment,
+    Release,
+    UserReport,
+    SnubaEvent
+)
 from sentry.search.utils import convert_user_tag_to_query
 from sentry.utils.safe import get_path
 
@@ -28,6 +35,7 @@ def get_crash_files(events):
     return rv
 
 
+@register(SnubaEvent)
 @register(Event)
 class EventSerializer(Serializer):
     _reserved_keys = frozenset(
@@ -70,7 +78,7 @@ class EventSerializer(Serializer):
         )
 
     def _get_interface_with_meta(self, event, name, is_public=False):
-        interface = event.interfaces.get(name)
+        interface = event.get_interface(name)
         if not interface:
             return (None, None)
 
@@ -96,11 +104,22 @@ class EventSerializer(Serializer):
                     'value': kv[1],
                     '_meta': meta.get(kv[0]) or get_path(meta, six.text_type(i), '1') or None,
                 }
+                # TODO this should be using event.tags but there are some weird
+                # issues around that because event.tags re-sorts the tags and
+                # this function relies on them being in the original order to
+                # look up meta.
                 for i, kv in enumerate(event.data.get('tags') or ())
                 if kv is not None and kv[0] is not None and kv[1] is not None],
             key=lambda x: x['key']
         )
 
+        # Add 'query' for each tag to tell the UI what to use as query
+        # params for this tag.
+        for tag in tags:
+            query = convert_user_tag_to_query(tag['key'], tag['value'])
+            if query:
+                tag['query'] = query
+
         tags_meta = {
             six.text_type(i): {'value': e.pop('_meta')}
             for i, e in enumerate(tags) if e.get('_meta')
@@ -221,6 +240,7 @@ class EventSerializer(Serializer):
             'id': six.text_type(obj.id),
             'groupID': six.text_type(obj.group_id),
             'eventID': six.text_type(obj.event_id),
+            'projectID': six.text_type(obj.project_id),
             'size': obj.size,
             'entries': attrs['entries'],
             'dist': obj.dist,
@@ -287,78 +307,50 @@ class SharedEventSerializer(EventSerializer):
         return result
 
 
-class SnubaEvent(object):
-    """
-        A simple wrapper class on a row (dict) returned from snuba representing
-        an event. Provides a class name to register a serializer against, and
-        Makes keys accessible as attributes.
-    """
-
-    # The list of columns that we should request from snuba to be able to fill
-    # out a proper event object.
-    selected_columns = [
-        'event_id',
-        'project_id',
-        'message',
-        'title',
-        'location',
-        'culprit',
-        'user_id',
-        'username',
-        'ip_address',
-        'email',
-        'timestamp',
-    ]
-
-    def __init__(self, kv):
-        assert len(set(self.selected_columns) - set(kv.keys())
-                   ) == 0, "SnubaEvents need all of the selected_columns"
-        self.__dict__ = kv
-
-
-@register(SnubaEvent)
-class SnubaEventSerializer(Serializer):
+class SimpleEventSerializer(EventSerializer):
     """
-        A bare-bones version of EventSerializer which uses snuba event rows as
-        the source data but attempts to produce a compatible (subset) of the
-        serialization returned by EventSerializer.
+    Simple event serializer that renders a basic outline of an event without
+    most interfaces/breadcrumbs. This can be used for basic event list queries
+    where we don't need the full detail. The side effect is that, if the
+    serialized events are actually SnubaEvents, we can render them without
+    needing to fetch the event bodies from nodestore.
+
+    NB it would be super easy to inadvertently add a property accessor here
+    that would require a nodestore lookup for a SnubaEvent serialized using
+    this serializer. You will only really notice you've done this when the
+    organization event search API gets real slow.
     """
 
-    def get_tags_dict(self, obj):
-        keys = getattr(obj, 'tags.key', None)
-        values = getattr(obj, 'tags.value', None)
-        if keys and values and len(keys) == len(values):
-            results = []
-            for key, value in zip(keys, values):
-                key = key.split('sentry:', 1)[-1]
-                result = {'key': key, 'value': value}
-                query = convert_user_tag_to_query(key, value)
-                if query:
-                    result['query'] = query
-                results.append(result)
-            results.sort(key=lambda x: x['key'])
-            return results
-        return []
+    def get_attrs(self, item_list, user):
+        return {}
 
     def serialize(self, obj, attrs, user):
-        result = {
+        tags = [{
+            'key': key.split('sentry:', 1)[-1],
+            'value': value,
+        } for key, value in obj.tags]
+        for tag in tags:
+            query = convert_user_tag_to_query(tag['key'], tag['value'])
+            if query:
+                tag['query'] = query
+
+        user = obj.get_interface('user')
+        if user is not None:
+            user = user.get_api_context()
+
+        return {
+            'id': six.text_type(obj.id),
+            'groupID': six.text_type(obj.group_id),
             'eventID': six.text_type(obj.event_id),
             'projectID': six.text_type(obj.project_id),
+            # XXX for 'message' this doesn't do the proper resolution of logentry
+            # etc. that _get_legacy_message_with_meta does.
             'message': obj.message,
             'title': obj.title,
             'location': obj.location,
             'culprit': obj.culprit,
-            'dateCreated': obj.timestamp,
-            'user': {
-                'id': obj.user_id,
-                'email': obj.email,
-                'username': obj.username,
-                'ipAddress': obj.ip_address,
-            },
+            'user': user,
+            'tags': tags,
+            'platform': obj.platform,
+            'dateCreated': obj.datetime,
         }
-
-        tags = self.get_tags_dict(obj)
-        if tags:
-            result['tags'] = tags
-
-        return result

+ 6 - 7
src/sentry/db/models/fields/node.py

@@ -27,7 +27,7 @@ from sentry.utils.canonical import CANONICAL_TYPES, CanonicalKeyDict
 
 from .gzippeddict import GzippedDictField
 
-__all__ = ('NodeField', )
+__all__ = ('NodeField', 'NodeData')
 
 logger = logging.getLogger('sentry')
 
@@ -100,10 +100,9 @@ class NodeData(collections.MutableMapping):
         return '<%s: id=%s>' % (cls_name, self.id, )
 
     def get_ref(self, instance):
-        ref_func = self.field.ref_func
-        if not ref_func:
+        if not self.field or not self.field.ref_func:
             return
-        return ref_func(instance)
+        return self.field.ref_func(instance)
 
     def copy(self):
         return self.data.copy()
@@ -126,18 +125,18 @@ class NodeData(collections.MutableMapping):
             return self._node_data
 
         rv = {}
-        if self.field.wrapper is not None:
+        if self.field is not None and self.field.wrapper is not None:
             rv = self.field.wrapper(rv)
         return rv
 
     def bind_data(self, data, ref=None):
         self.ref = data.pop('_ref', ref)
         self.ref_version = data.pop('_ref_version', None)
-        if self.ref_version == self.field.ref_version and ref is not None and self.ref != ref:
+        if self.field is not None and self.ref_version == self.field.ref_version and ref is not None and self.ref != ref:
             raise NodeIntegrityFailure(
                 'Node reference for %s is invalid: %s != %s' % (self.id, ref, self.ref, )
             )
-        if self.field.wrapper is not None:
+        if self.field is not None and self.field.wrapper is not None:
             data = self.field.wrapper(data)
         self._node_data = data
 

+ 6 - 0
src/sentry/db/models/manager.py

@@ -320,6 +320,12 @@ class BaseManager(Manager):
 class EventManager(BaseManager):
 
     def bind_nodes(self, object_list, *node_names):
+        """
+        For a list of Event objects, and a property name where we might find an
+        (unfetched) NodeData on those objects, fetch all the data blobs for
+        those NodeDatas with a single multi-get command to nodestore, and bind
+        the returned blobs to the NodeDatas
+        """
         object_node_list = []
         for name in node_names:
             object_node_list.extend(

+ 284 - 111
src/sentry/models/event.py

@@ -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
 

+ 1 - 1
src/sentry/plugins/base/v1.py

@@ -444,7 +444,7 @@ class IPlugin(local, PluggableViewMixin, PluginConfigMixin, PluginStatusMixin):
         >>> def is_regression(self, group, event, **kwargs):
         >>>     # regression if 'version' tag has a value we haven't seen before
         >>>     seen_versions = set(t[0] for t in group.get_unique_tags("version"))
-        >>>     event_version = dict(event.get_tags()).get("version")
+        >>>     event_version = dict(event.tags).get("version")
         >>>     return event_version not in seen_versions
         """
 

+ 1 - 1
src/sentry/plugins/sentry_mail/models.py

@@ -263,7 +263,7 @@ class MailPlugin(NotificationPlugin):
                 interface_list.append((interface.get_title(), mark_safe(body), text_body))
 
             context.update({
-                'tags': event.get_tags(),
+                'tags': event.tags,
                 'interfaces': interface_list,
             })
 

+ 1 - 1
src/sentry/plugins/sentry_webhooks/plugin.py

@@ -90,7 +90,7 @@ class WebHooksPlugin(notify.NotificationPlugin):
             'triggering_rules': triggering_rules,
         }
         data['event'] = dict(event.data or {})
-        data['event']['tags'] = event.get_tags()
+        data['event']['tags'] = event.tags
         data['event']['event_id'] = event.event_id
         if features.has('organizations:legacy-event-id', group.project.organization):
             try:

Some files were not shown because too many files changed in this diff