Esempio n. 1
0
class ProjectPlatform(Model):
    """
    Tracks usage of a platform for a given project.

    Note: This model is used solely for analytics.
    """

    __core__ = False

    project_id = BoundedBigIntegerField()
    platform = models.CharField(max_length=64)
    date_added = models.DateTimeField(default=timezone.now)
    last_seen = models.DateTimeField(default=timezone.now)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_projectplatform"
        unique_together = (("project_id", "platform"), )

    __repr__ = sane_repr("project_id", "platform")
Esempio n. 2
0
class Counter(Model):
    """
    A ReleaseFile is an association between a Release and a File.

    The ident of the file should be sha1(name) and must be unique per release.
    """
    __core__ = False

    project = FlexibleForeignKey('sentry.Project', unique=True)
    value = BoundedBigIntegerField()

    __repr__ = sane_repr('project')

    class Meta:
        app_label = 'sentry'
        db_table = 'sentry_projectcounter'

    @classmethod
    def increment(cls, project, delta=1):
        """Increments a counter.  This can never decrement."""
        return increment_project_counter(project, delta)
class DeletedTeam(DeletedEntry):
    """
    This model tracks an intent to delete. If an org is marked pending_delete
    through the UI, a deletedteam is created to log this deletion.

    This model does not account for aborted or failed deletions and is currently
    unable to log deletions that occur implicity (i.e. when the sole parent object
    is deleted, the child is also marked for deletion as well).
    """
    name = models.CharField(max_length=64, null=True)
    slug = models.CharField(max_length=50, null=True)

    organization_id = BoundedBigIntegerField(null=True)
    organization_name = models.CharField(max_length=64, null=True)
    organization_slug = models.CharField(max_length=50, null=True)

    class Meta:
        app_label = 'sentry'
        db_table = 'sentry_deletedteam'

    __repr__ = sane_repr('date_deleted', 'slug', 'reason')
Esempio n. 4
0
class ProjectBookmark(Model):
    """
    Identifies a bookmark relationship between a user and an
    aggregated event (Group).
    """
    __core__ = True

    project_id = BoundedBigIntegerField(blank=True, null=True)
    user = FlexibleForeignKey(settings.AUTH_USER_MODEL)
    date_added = models.DateTimeField(default=timezone.now, null=True)

    objects = BaseManager()

    class Meta:
        app_label = 'sentry'
        db_table = 'sentry_projectbookmark'
        unique_together = ((
            'project_id',
            'user',
        ))

    __repr__ = sane_repr('project_id', 'user_id')
Esempio n. 5
0
class UserReport(Model):
    __core__ = False

    project = FlexibleForeignKey('sentry.Project')
    group = FlexibleForeignKey('sentry.Group', null=True)
    event_user_id = BoundedBigIntegerField(null=True)
    event_id = models.CharField(max_length=32)
    environment = FlexibleForeignKey('sentry.Environment', null=True)
    name = models.CharField(max_length=128)
    email = models.EmailField(max_length=75)
    comments = models.TextField()
    date_added = models.DateTimeField(default=timezone.now, db_index=True)

    class Meta:
        app_label = 'sentry'
        db_table = 'sentry_userreport'
        index_together = (('project', 'event_id'), ('project', 'date_added'))
        unique_together = (('project', 'event_id'), )

    __repr__ = sane_repr('event_id', 'name', 'email')

    def notify(self):
        from django.contrib.auth.models import AnonymousUser
        from sentry.api.serializers import (
            serialize,
            UserReportWithGroupSerializer,
        )
        from sentry.tasks.signals import signal

        signal.delay(
            name='user-reports.created',
            project_id=self.project_id,
            payload={
                'report':
                serialize(self, AnonymousUser(),
                          UserReportWithGroupSerializer()),
            },
        )
Esempio n. 6
0
class IntegrationFeature(Model):
    __include_in_export__ = False

    objects = IntegrationFeatureManager()

    # the id of the sentry_app or doc_integration
    target_id = BoundedBigIntegerField()
    target_type = BoundedPositiveIntegerField(
        default=0,
        choices=(
            (IntegrationTypes.SENTRY_APP, "sentry_app"),
            (IntegrationTypes.DOC_INTEGRATION, "doc_integration"),
        ),
    )
    user_description = models.TextField(null=True)
    feature = BoundedPositiveIntegerField(default=0, choices=Feature.as_choices())
    date_added = models.DateTimeField(default=timezone.now)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_integrationfeature"
        unique_together = (("target_id", "target_type", "feature"),)

    def feature_str(self):
        return Feature.as_str(self.feature)

    @property
    def description(self):
        from sentry.models import DocIntegration, SentryApp

        if self.user_description:
            return self.user_description

        if self.target_type == IntegrationTypes.SENTRY_APP.value:
            integration = SentryApp.objects.get(id=self.target_id)
        else:
            integration = DocIntegration.objects.get(id=self.target_id)
        return Feature.description(self.feature, integration.name)
Esempio n. 7
0
class GroupTombstone(Model):
    __core__ = False

    previous_group_id = BoundedBigIntegerField(unique=True)
    project = FlexibleForeignKey("sentry.Project")
    level = BoundedPositiveIntegerField(
        choices=[(key, six.text_type(val))
                 for key, val in sorted(LOG_LEVELS.items())],
        default=logging.ERROR,
        blank=True,
    )
    message = models.TextField()
    culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH,
                               blank=True,
                               null=True)
    data = GzippedDictField(blank=True, null=True)
    actor_id = BoundedPositiveIntegerField(null=True)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_grouptombstone"

    def get_event_type(self):
        """
        Return the type of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data.get("type", "default")

    def get_event_metadata(self):
        """
        Return the metadata of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data["metadata"]
Esempio n. 8
0
class DeletedProject(DeletedEntry):
    """
    This model tracks an intent to delete. If an org is marked pending_delete
    through the UI, a deletedproject is created to log this deletion.

    This model does not account for aborted or failed deletions and is currently
    unable to log deletions that occur implicitly (i.e. when the sole parent object
    is deleted, the child is also marked for deletion as well).
    """

    slug = models.CharField(max_length=50, null=True)
    name = models.CharField(max_length=200, null=True)

    organization_id = BoundedBigIntegerField(null=True)
    organization_name = models.CharField(max_length=64, null=True)
    organization_slug = models.CharField(max_length=50, null=True)

    platform = models.CharField(max_length=64, null=True)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_deletedproject"

    __repr__ = sane_repr("date_deleted", "slug", "reason")
Esempio n. 9
0
class Group(Model):
    """
    Aggregated message which summarizes a set of Events.
    """

    __core__ = False

    project = FlexibleForeignKey("sentry.Project")
    logger = models.CharField(max_length=64,
                              blank=True,
                              default=DEFAULT_LOGGER_NAME,
                              db_index=True)
    level = BoundedPositiveIntegerField(choices=LOG_LEVELS.items(),
                                        default=logging.ERROR,
                                        blank=True,
                                        db_index=True)
    message = models.TextField()
    culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH,
                               blank=True,
                               null=True,
                               db_column="view")
    num_comments = BoundedPositiveIntegerField(default=0, null=True)
    platform = models.CharField(max_length=64, null=True)
    status = BoundedPositiveIntegerField(
        default=0,
        choices=(
            (GroupStatus.UNRESOLVED, _("Unresolved")),
            (GroupStatus.RESOLVED, _("Resolved")),
            (GroupStatus.IGNORED, _("Ignored")),
        ),
        db_index=True,
    )
    times_seen = BoundedPositiveIntegerField(default=1, db_index=True)
    last_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_release = FlexibleForeignKey("sentry.Release",
                                       null=True,
                                       on_delete=models.PROTECT)
    resolved_at = models.DateTimeField(null=True, db_index=True)
    # active_at should be the same as first_seen by default
    active_at = models.DateTimeField(null=True, db_index=True)
    time_spent_total = BoundedIntegerField(default=0)
    time_spent_count = BoundedIntegerField(default=0)
    score = BoundedIntegerField(default=0)
    # deprecated, do not use. GroupShare has superseded
    is_public = models.NullBooleanField(default=False, null=True)
    data = GzippedDictField(blank=True, null=True)
    short_id = BoundedBigIntegerField(null=True)

    objects = GroupManager(cache_fields=("id", ))

    class Meta:
        app_label = "sentry"
        db_table = "sentry_groupedmessage"
        verbose_name_plural = _("grouped messages")
        verbose_name = _("grouped message")
        permissions = (("can_view", "Can view"), )
        index_together = [("project", "first_release"), ("project", "id")]
        unique_together = (("project", "short_id"), )

    __repr__ = sane_repr("project_id")

    def __unicode__(self):
        return "(%s) %s" % (self.times_seen, self.error())

    def save(self, *args, **kwargs):
        if not self.last_seen:
            self.last_seen = timezone.now()
        if not self.first_seen:
            self.first_seen = self.last_seen
        if not self.active_at:
            self.active_at = self.first_seen
        # We limit what we store for the message body
        self.message = strip(self.message)
        if self.message:
            self.message = truncatechars(self.message.splitlines()[0], 255)
        if self.times_seen is None:
            self.times_seen = 1
        self.score = type(self).calculate_score(times_seen=self.times_seen,
                                                last_seen=self.last_seen)
        super(Group, self).save(*args, **kwargs)

    def get_absolute_url(self, params=None):
        url = reverse("sentry-organization-issue",
                      args=[self.organization.slug, self.id])
        if params:
            url = url + "?" + urlencode(params)
        return absolute_uri(url)

    @property
    def qualified_short_id(self):
        if self.short_id is not None:
            return "%s-%s" % (self.project.slug.upper(),
                              base32_encode(self.short_id))

    def is_over_resolve_age(self):
        resolve_age = self.project.get_option("sentry:resolve_age", None)
        if not resolve_age:
            return False
        return self.last_seen < timezone.now() - timedelta(
            hours=int(resolve_age))

    def is_ignored(self):
        return self.get_status() == GroupStatus.IGNORED

    def is_unresolved(self):
        return self.get_status() == GroupStatus.UNRESOLVED

    # TODO(dcramer): remove in 9.0 / after plugins no long ref
    is_muted = is_ignored

    def is_resolved(self):
        return self.get_status() == GroupStatus.RESOLVED

    def get_status(self):
        # XXX(dcramer): GroupSerializer reimplements this logic
        from sentry.models import GroupSnooze

        status = self.status

        if status == GroupStatus.IGNORED:
            try:
                snooze = GroupSnooze.objects.get_from_cache(group=self)
            except GroupSnooze.DoesNotExist:
                pass
            else:
                if not snooze.is_valid(group=self):
                    status = GroupStatus.UNRESOLVED

        if status == GroupStatus.UNRESOLVED and self.is_over_resolve_age():
            return GroupStatus.RESOLVED
        return status

    def get_share_id(self):
        from sentry.models import GroupShare

        try:
            return GroupShare.objects.filter(group_id=self.id).values_list(
                "uuid", flat=True)[0]
        except IndexError:
            # Otherwise it has not been shared yet.
            return None

    @classmethod
    def from_share_id(cls, share_id):
        if not share_id or len(share_id) != 32:
            raise cls.DoesNotExist

        from sentry.models import GroupShare

        return cls.objects.get(id=GroupShare.objects.filter(
            uuid=share_id).values_list("group_id"))

    def get_score(self):
        return type(self).calculate_score(self.times_seen, self.last_seen)

    def get_latest_event(self):
        if not hasattr(self, "_latest_event"):
            self._latest_event = self.get_latest_event_for_environments()

        return self._latest_event

    def get_latest_event_for_environments(self, environments=()):
        return get_oldest_or_latest_event_for_environments(
            EventOrdering.LATEST,
            environments=environments,
            issue_id=self.id,
            project_id=self.project_id,
        )

    def get_oldest_event_for_environments(self, environments=()):
        return get_oldest_or_latest_event_for_environments(
            EventOrdering.OLDEST,
            environments=environments,
            issue_id=self.id,
            project_id=self.project_id,
        )

    def get_first_release(self):
        if self.first_release_id is None:
            return tagstore.get_first_release(self.project_id, self.id)

        return self.first_release.version

    def get_last_release(self):
        return tagstore.get_last_release(self.project_id, self.id)

    def get_event_type(self):
        """
        Return the type of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data.get("type", "default")

    def get_event_metadata(self):
        """
        Return the metadata of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data["metadata"]

    @property
    def title(self):
        et = eventtypes.get(self.get_event_type())()
        return et.get_title(self.get_event_metadata())

    def location(self):
        et = eventtypes.get(self.get_event_type())()
        return et.get_location(self.get_event_metadata())

    def error(self):
        warnings.warn("Group.error is deprecated, use Group.title",
                      DeprecationWarning)
        return self.title

    error.short_description = _("error")

    @property
    def message_short(self):
        warnings.warn("Group.message_short is deprecated, use Group.title",
                      DeprecationWarning)
        return self.title

    @property
    def organization(self):
        return self.project.organization

    @property
    def checksum(self):
        warnings.warn("Group.checksum is no longer used", DeprecationWarning)
        return ""

    def get_email_subject(self):
        return "%s - %s" % (self.qualified_short_id.encode("utf-8"),
                            self.title.encode("utf-8"))

    def count_users_seen(self):
        return tagstore.get_groups_user_counts([self.project_id], [self.id],
                                               environment_ids=None,
                                               start=self.first_seen)[self.id]

    @classmethod
    def calculate_score(cls, times_seen, last_seen):
        return math.log(float(times_seen or 1)) * 600 + float(
            last_seen.strftime("%s"))
Esempio n. 10
0
class GroupRelease(Model):
    __core__ = False

    # TODO: Should be BoundedBigIntegerField
    project_id = BoundedPositiveIntegerField(db_index=True)
    group_id = BoundedBigIntegerField()
    # TODO: Should be BoundedBigIntegerField
    release_id = BoundedPositiveIntegerField(db_index=True)
    environment = models.CharField(max_length=64, default="")
    first_seen = models.DateTimeField(default=timezone.now)
    last_seen = models.DateTimeField(default=timezone.now, db_index=True)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_grouprelease"
        unique_together = (("group_id", "release_id", "environment"), )
        index_together = (
            ("group_id", "first_seen"),
            ("group_id", "last_seen"),
        )

    __repr__ = sane_repr("group_id", "release_id")

    @classmethod
    def get_cache_key(cls, group_id, release_id, environment):
        return "grouprelease:1:{}:{}".format(
            group_id,
            md5_text(f"{release_id}:{environment}").hexdigest())

    @classmethod
    def get_or_create(cls, group, release, environment, datetime, **kwargs):
        cache_key = cls.get_cache_key(group.id, release.id, environment.name)

        instance = cache.get(cache_key)
        if instance is None:
            try:
                with transaction.atomic():
                    instance, created = (
                        cls.objects.create(
                            release_id=release.id,
                            group_id=group.id,
                            environment=environment.name,
                            project_id=group.project_id,
                            first_seen=datetime,
                            last_seen=datetime,
                        ),
                        True,
                    )
            except IntegrityError:
                instance, created = (
                    cls.objects.get(release_id=release.id,
                                    group_id=group.id,
                                    environment=environment.name),
                    False,
                )
            cache.set(cache_key, instance, 3600)
        else:
            created = False

        # TODO(dcramer): this would be good to buffer, but until then we minimize
        # updates to once a minute, and allow Postgres to optimistically skip
        # it even if we can't
        if not created and instance.last_seen < datetime - timedelta(
                seconds=60):
            cls.objects.filter(
                id=instance.id, last_seen__lt=datetime -
                timedelta(seconds=60)).update(last_seen=datetime)
            instance.last_seen = datetime
            cache.set(cache_key, instance, 3600)
        return instance
Esempio n. 11
0
class Group(Model):
    """
    Aggregated message which summarizes a set of Events.
    """
    __core__ = False

    project = FlexibleForeignKey('sentry.Project', null=True)
    logger = models.CharField(max_length=64,
                              blank=True,
                              default=DEFAULT_LOGGER_NAME,
                              db_index=True)
    level = BoundedPositiveIntegerField(choices=LOG_LEVELS.items(),
                                        default=logging.ERROR,
                                        blank=True,
                                        db_index=True)
    message = models.TextField()
    culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH,
                               blank=True,
                               null=True,
                               db_column='view')
    num_comments = BoundedPositiveIntegerField(default=0, null=True)
    platform = models.CharField(max_length=64, null=True)
    status = BoundedPositiveIntegerField(default=0,
                                         choices=(
                                             (GroupStatus.UNRESOLVED,
                                              _('Unresolved')),
                                             (GroupStatus.RESOLVED,
                                              _('Resolved')),
                                             (GroupStatus.MUTED, _('Muted')),
                                         ),
                                         db_index=True)
    times_seen = BoundedPositiveIntegerField(default=1, db_index=True)
    last_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_release = FlexibleForeignKey('sentry.Release',
                                       null=True,
                                       on_delete=models.PROTECT)
    resolved_at = models.DateTimeField(null=True, db_index=True)
    # active_at should be the same as first_seen by default
    active_at = models.DateTimeField(null=True, db_index=True)
    time_spent_total = BoundedIntegerField(default=0)
    time_spent_count = BoundedIntegerField(default=0)
    score = BoundedIntegerField(default=0)
    is_public = models.NullBooleanField(default=False, null=True)
    data = GzippedDictField(blank=True, null=True)
    short_id = BoundedBigIntegerField(null=True)

    objects = GroupManager()

    class Meta:
        app_label = 'sentry'
        db_table = 'sentry_groupedmessage'
        verbose_name_plural = _('grouped messages')
        verbose_name = _('grouped message')
        permissions = (("can_view", "Can view"), )
        index_together = (('project', 'first_release'), )
        unique_together = (('project', 'short_id'), )

    __repr__ = sane_repr('project_id')

    def __unicode__(self):
        return "(%s) %s" % (self.times_seen, self.error())

    def save(self, *args, **kwargs):
        if not self.last_seen:
            self.last_seen = timezone.now()
        if not self.first_seen:
            self.first_seen = self.last_seen
        if not self.active_at:
            self.active_at = self.first_seen
        # We limit what we store for the message body
        self.message = strip(self.message)
        if self.message:
            self.message = truncatechars(self.message.splitlines()[0], 255)
        super(Group, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return absolute_uri(
            reverse('sentry-group',
                    args=[self.organization.slug, self.project.slug, self.id]))

    @property
    def qualified_short_id(self):
        if self.short_id is not None:
            return '%s-%s' % (
                self.project.slug.upper(),
                base32_encode(self.short_id),
            )

    @property
    def event_set(self):
        from sentry.models import Event
        return Event.objects.filter(group_id=self.id)

    def is_over_resolve_age(self):
        resolve_age = self.project.get_option('sentry:resolve_age', None)
        if not resolve_age:
            return False
        return self.last_seen < timezone.now() - timedelta(
            hours=int(resolve_age))

    def is_muted(self):
        return self.get_status() == GroupStatus.MUTED

    def is_resolved(self):
        return self.get_status() == GroupStatus.RESOLVED

    def get_status(self):
        # XXX(dcramer): GroupSerializer reimplements this logic
        from sentry.models import GroupSnooze

        if self.status == GroupStatus.MUTED:
            try:
                snooze = GroupSnooze.objects.get(group=self)
            except GroupSnooze.DoesNotExist:
                pass
            else:
                # XXX(dcramer): if the snooze row exists then we need
                # to confirm its still valid
                if snooze.until > timezone.now():
                    return GroupStatus.MUTED
                else:
                    return GroupStatus.UNRESOLVED

        if self.status == GroupStatus.UNRESOLVED and self.is_over_resolve_age(
        ):
            return GroupStatus.RESOLVED
        return self.status

    def get_share_id(self):
        return b16encode(
            ('{}.{}'.format(self.project_id,
                            self.id)).encode('utf-8')).lower().decode('utf-8')

    @classmethod
    def from_share_id(cls, share_id):
        if not share_id:
            raise cls.DoesNotExist
        try:
            project_id, group_id = b16decode(
                share_id.upper()).decode('utf-8').split('.')
        except (ValueError, TypeError):
            raise cls.DoesNotExist
        if not (project_id.isdigit() and group_id.isdigit()):
            raise cls.DoesNotExist
        return cls.objects.get(project=project_id, id=group_id)

    def get_score(self):
        return int(
            math.log(self.times_seen) * 600 +
            float(time.mktime(self.last_seen.timetuple())))

    def get_latest_event(self):
        from sentry.models import Event

        if not hasattr(self, '_latest_event'):
            latest_events = sorted(
                Event.objects.filter(
                    group_id=self.id, ).order_by('-datetime')[0:5],
                key=EVENT_ORDERING_KEY,
                reverse=True,
            )
            try:
                self._latest_event = latest_events[0]
            except IndexError:
                self._latest_event = None
        return self._latest_event

    def get_oldest_event(self):
        from sentry.models import Event

        if not hasattr(self, '_oldest_event'):
            oldest_events = sorted(
                Event.objects.filter(
                    group_id=self.id, ).order_by('datetime')[0:5],
                key=EVENT_ORDERING_KEY,
            )
            try:
                self._oldest_event = oldest_events[0]
            except IndexError:
                self._oldest_event = None
        return self._oldest_event

    def get_unique_tags(self, tag, since=None, order_by='-times_seen'):
        # TODO(dcramer): this has zero test coverage and is a critical path
        from sentry.models import GroupTagValue

        queryset = GroupTagValue.objects.filter(
            group=self,
            key=tag,
        )
        if since:
            queryset = queryset.filter(last_seen__gte=since)
        return queryset.values_list(
            'value',
            'times_seen',
            'first_seen',
            'last_seen',
        ).order_by(order_by)

    def get_tags(self, with_internal=True):
        from sentry.models import GroupTagKey, TagKey
        if not hasattr(self, '_tag_cache'):
            group_tags = GroupTagKey.objects.filter(
                group=self,
                project=self.project,
            )
            if not with_internal:
                group_tags = group_tags.exclude(key__startswith='sentry:')

            group_tags = list(group_tags.values_list('key', flat=True))

            tag_keys = dict(
                (t.key, t) for t in TagKey.objects.filter(project=self.project,
                                                          key__in=group_tags))

            results = []
            for key in group_tags:
                try:
                    tag_key = tag_keys[key]
                except KeyError:
                    label = key.replace('_', ' ').title()
                else:
                    label = tag_key.get_label()

                results.append({
                    'key': key,
                    'label': label,
                })

            self._tag_cache = sorted(results, key=lambda x: x['label'])

        return self._tag_cache

    def get_event_type(self):
        """
        Return the type of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data.get('type', 'default')

    def get_event_metadata(self):
        """
        Return the metadata of this issue.

        See ``sentry.eventtypes``.
        """
        etype = self.data.get('type')
        if etype is None:
            etype = 'default'
        if 'metadata' not in self.data:
            data = self.data.copy() if self.data else {}
            data['message'] = self.message
            return eventtypes.get(etype)(data).get_metadata()
        return self.data['metadata']

    @property
    def title(self):
        et = eventtypes.get(self.get_event_type())(self.data)
        return et.to_string(self.get_event_metadata())

    def error(self):
        warnings.warn('Group.error is deprecated, use Group.title',
                      DeprecationWarning)
        return self.title

    error.short_description = _('error')

    @property
    def message_short(self):
        warnings.warn('Group.message_short is deprecated, use Group.title',
                      DeprecationWarning)
        return self.title

    def has_two_part_message(self):
        warnings.warn('Group.has_two_part_message is no longer used',
                      DeprecationWarning)
        return False

    @property
    def organization(self):
        return self.project.organization

    @property
    def team(self):
        return self.project.team

    @property
    def checksum(self):
        warnings.warn('Group.checksum is no longer used', DeprecationWarning)
        return ''

    def get_email_subject(self):
        return '[%s] %s: %s' % (self.project.get_full_name().encode('utf-8'),
                                six.text_type(self.get_level_display()).upper(
                                ).encode('utf-8'), self.title.encode('utf-8'))
Esempio n. 12
0
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=ref_func,
        ref_version=2,
        wrapper=CanonicalKeyDict,
    )

    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')

    @classmethod
    def generate_node_id(cls, project_id, event_id):
        """
        Returns a deterministic node_id for this event based on the project_id
        and event_id which together are globally unique. The event body should
        be saved under this key in nodestore so it can be retrieved using the
        same generated id when we only have project_id and event_id.
        """
        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):
        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

    def _get_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)

    def get_legacy_message(self):
        # TODO(mitsuhiko): remove this code once it's unused.  It's still
        # being used by plugin code and once the message rename is through
        # plugins should instead swithc to the actual message attribute or
        # this method could return what currently is real_message.
        return get_path(self.data, 'logentry', 'formatted') \
            or get_path(self.data, 'logentry', 'message') \
            or self.message

    def get_event_type(self):
        """
        Return the type of this event.

        See ``sentry.eventtypes``.
        """
        return self.data.get('type', 'default')

    def get_event_metadata(self):
        """
        Return the metadata of this event.

        See ``sentry.eventtypes``.
        """
        from sentry.event_manager import get_event_metadata_compat
        return get_event_metadata_compat(self.data, self.message)

    def get_hashes(self):
        """
        Returns the calculated hashes for the event.
        """
        from sentry.event_hashing import calculate_event_hashes
        # If we have hashes stored in the data we use them, otherwise we
        # fall back to generating new ones from the data
        hashes = self.data.get('hashes')
        if hashes is not None:
            return hashes
        return calculate_event_hashes(self)

    def get_primary_hash(self):
        # TODO: This *might* need to be protected from an IndexError?
        return self.get_hashes()[0]

    @property
    def title(self):
        et = eventtypes.get(self.get_event_type())(self.data)
        return et.to_string(self.get_event_metadata())

    def error(self):
        warnings.warn('Event.error is deprecated, use Event.title', DeprecationWarning)
        return self.title

    error.short_description = _('error')

    @property
    def real_message(self):
        # XXX(mitsuhiko): this is a transitional attribute that should be
        # removed.  `message` will be renamed to `search_message` and this
        # will become `message`.
        return get_path(self.data, 'logentry', 'formatted') \
            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

    @property
    def version(self):
        return self.data.get('version', '5')

    @memoize
    def ip_address(self):
        ip_address = get_path(self.data, 'user', 'ip_address')
        if ip_address:
            return ip_address

        remote_addr = get_path(self.data, 'request', 'env', 'REMOTE_ADDR')
        if remote_addr:
            return remote_addr

        return None

    def get_interfaces(self):
        return CanonicalKeyView(get_interfaces(self.data))

    @memoize
    def interfaces(self):
        return self.get_interfaces()

    def get_tags(self, sorted=True):
        try:
            rv = [(t, v) for t, v in get_path(
                self.data, 'tags', filter=True) or () if v is not None]
            if sorted:
                rv.sort()
            return rv
        except ValueError:
            # at one point Sentry allowed invalid tag sets such as (foo, bar)
            # vs ((tag, foo), (tag, bar))
            return []

    tags = property(get_tags)

    def get_tag(self, key):
        for t, v in self.get_tags():
            if t == key:
                return v
        return None

    @property
    def release(self):
        return self.get_tag('sentry:release')

    @property
    def dist(self):
        return self.get_tag('sentry:dist')

    def as_dict(self):
        # We use a OrderedDict to keep elements ordered for a potential JSON serializer
        data = OrderedDict()
        data['event_id'] = self.event_id
        data['project'] = self.project_id
        data['release'] = self.release
        data['dist'] = self.dist
        data['platform'] = self.platform
        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()]
        for k, v in sorted(six.iteritems(self.data)):
            if k in data:
                continue
            if k == 'sdk':
                v = {v_k: v_v for v_k, v_v in six.iteritems(v) if v_k != 'client_ip'}
            data[k] = v

        # for a long time culprit was not persisted.  In those cases put
        # the culprit in from the group.
        if data.get('culprit') is None:
            data['culprit'] = self.group.culprit

        return data

    @property
    def size(self):
        data_len = 0
        for value in six.itervalues(self.data):
            data_len += len(repr(value))
        return data_len

    # XXX(dcramer): compatibility with plugins
    def get_level_display(self):
        warnings.warn(
            'Event.get_level_display is deprecated. Use Event.tags instead.', DeprecationWarning
        )
        return self.group.get_level_display()

    @property
    def level(self):
        warnings.warn('Event.level is deprecated. Use Event.tags instead.', DeprecationWarning)
        return self.group.level

    @property
    def logger(self):
        warnings.warn('Event.logger is deprecated. Use Event.tags instead.', DeprecationWarning)
        return self.get_tag('logger')

    @property
    def site(self):
        warnings.warn('Event.site is deprecated. Use Event.tags instead.', DeprecationWarning)
        return self.get_tag('site')

    @property
    def server_name(self):
        warnings.warn('Event.server_name is deprecated. Use Event.tags instead.')
        return self.get_tag('server_name')

    @property
    def culprit(self):
        warnings.warn('Event.culprit is deprecated. Use Group.culprit instead.')
        return self.group.culprit

    @property
    def checksum(self):
        warnings.warn('Event.checksum is no longer used', DeprecationWarning)
        return ''

    @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

    # 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
    # we grab the next 5 / prev 5 events by datetime, and sort locally to
    # get the next/prev events. Given that timestamps only have 1-second
    # granularity, this will be inaccurate if there are more than 5 events
    # in a given second.
    @property
    def next_event(self):
        qs = self.__class__.objects.filter(
            # To be 'after', an event needs either a higher datetime,
            # or the same datetime and a higher id.
            (
                Q(datetime__gt=self.datetime) |
                (Q(datetime=self.datetime) & Q(id__gt=self.id))
            ),
            group_id=self.group_id,
        ).exclude(id=self.id).order_by('datetime')

        try:
            return sorted(qs[0:5], key=EVENT_ORDERING_KEY)[0]
        except IndexError:
            return None

    @property
    def prev_event(self):
        qs = self.__class__.objects.filter(
            # To be 'before', an event needs either a lower datetime,
            # or the same datetime and a lower id.
            (
                Q(datetime__lt=self.datetime) |
                (Q(datetime=self.datetime) & Q(id__lt=self.id))
            ),
            group_id=self.group_id,
        ).exclude(id=self.id).order_by('-datetime')

        try:
            return sorted(qs[0:5], key=EVENT_ORDERING_KEY, reverse=True)[0]
        except IndexError:
            return None
Esempio n. 13
0
class DummyModel(Model):
    foo = models.CharField(max_length=32)
    normint = BoundedIntegerField(null=True)
    bigint = BoundedBigIntegerField(null=True)
    posint = BoundedPositiveIntegerField(null=True)
Esempio n. 14
0
class Release(Model):
    """
    A release is generally created when a new version is pushed into a
    production state.

    A commit is generally a git commit. See also releasecommit.py
    """

    __include_in_export__ = False

    organization = FlexibleForeignKey("sentry.Organization")
    projects = models.ManyToManyField("sentry.Project",
                                      related_name="releases",
                                      through=ReleaseProject)
    status = BoundedPositiveIntegerField(
        default=ReleaseStatus.OPEN,
        null=True,
        choices=(
            (ReleaseStatus.OPEN, _("Open")),
            (ReleaseStatus.ARCHIVED, _("Archived")),
        ),
    )

    # DEPRECATED
    project_id = BoundedPositiveIntegerField(null=True)
    version = models.CharField(max_length=DB_VERSION_LENGTH)
    # ref might be the branch name being released
    ref = models.CharField(max_length=DB_VERSION_LENGTH, null=True, blank=True)
    url = models.URLField(null=True, blank=True)
    date_added = models.DateTimeField(default=timezone.now)
    # DEPRECATED - not available in UI or editable from API
    date_started = models.DateTimeField(null=True, blank=True)
    date_released = models.DateTimeField(null=True, blank=True)
    # arbitrary data recorded with the release
    data = JSONField(default={})
    # new issues (groups) that arise as a consequence of this release
    new_groups = BoundedPositiveIntegerField(default=0)
    # generally the release manager, or the person initiating the process
    owner = FlexibleForeignKey("sentry.User",
                               null=True,
                               blank=True,
                               on_delete=models.SET_NULL)

    # materialized stats
    commit_count = BoundedPositiveIntegerField(null=True, default=0)
    last_commit_id = BoundedBigIntegerField(null=True)
    authors = ArrayField(null=True)
    total_deploys = BoundedPositiveIntegerField(null=True, default=0)
    last_deploy_id = BoundedPositiveIntegerField(null=True)

    # Denormalized semver columns. These will be filled if `version` matches at least
    # part of our more permissive model of semver:
    # `<package>@<major>.<minor>.<patch>.<revision>-<prerelease>+<build_code>
    package = models.TextField(null=True)
    major = models.BigIntegerField(null=True)
    minor = models.BigIntegerField(null=True)
    patch = models.BigIntegerField(null=True)
    revision = models.BigIntegerField(null=True)
    prerelease = models.TextField(null=True)
    build_code = models.TextField(null=True)
    # If `build_code` can be parsed as a 64 bit int we'll store it here as well for
    # sorting/comparison purposes
    build_number = models.BigIntegerField(null=True)

    # HACK HACK HACK
    # As a transitional step we permit release rows to exist multiple times
    # where they are "specialized" for a specific project.  The goal is to
    # later split up releases by project again.  This is for instance used
    # by the org release listing.
    _for_project_id = None

    # Custom Model Manager required to override create method
    objects = ReleaseModelManager()

    class Meta:
        app_label = "sentry"
        db_table = "sentry_release"
        unique_together = (("organization", "version"), )
        # TODO(django2.2): Note that we create this index with each column ordered
        # descending. Django 2.2 allows us to specify functional indexes, which should
        # allow us to specify this on the model.
        # We also use a functional index to order `prerelease` according to semver rules,
        # which we can't express here for now.
        index_together = (
            ("organization", "package", "major", "minor", "patch", "revision",
             "prerelease"),
            ("organization", "major", "minor", "patch", "revision",
             "prerelease"),
            ("organization", "build_code"),
            ("organization", "build_number"),
            ("organization", "date_added"),
            ("organization", "status"),
        )
        indexes = [
            models.Index(
                fields=["organization", "version"],
                opclasses=["", "text_pattern_ops"],
                name="sentry_release_version_btree",
            )
        ]

    __repr__ = sane_repr("organization_id", "version")

    SEMVER_COLS = [
        "major", "minor", "patch", "revision", "prerelease_case", "prerelease"
    ]

    def __eq__(self, other):
        """Make sure that specialized releases are only comparable to the same
        other specialized release.  This for instance lets us treat them
        separately for serialization purposes.
        """
        return Model.__eq__(
            self, other) and self._for_project_id == other._for_project_id

    def __hash__(self):
        # https://code.djangoproject.com/ticket/30333
        return super().__hash__()

    @staticmethod
    def is_valid_version(value):
        return not (not value or any(c in value for c in BAD_RELEASE_CHARS)
                    or value in (".", "..") or value.lower() == "latest")

    @property
    def is_semver_release(self):
        return self.package is not None

    @staticmethod
    def is_semver_version(version):
        """
        Method that checks if a version follows semantic versioning
        """
        # If version is not a valid release version, or it has no package then we return False
        if not Release.is_valid_version(version) or "@" not in version:
            return False

        try:
            version_info = parse_release(version)
            version_parsed = version_info.get("version_parsed")
            return version_parsed is not None and all(
                validate_bigint(version_parsed[field])
                for field in ("major", "minor", "patch", "revision"))
        except RelayError:
            # This can happen on invalid legacy releases
            return False

    @classmethod
    def get_cache_key(cls, organization_id, version):
        return f"release:3:{organization_id}:{md5_text(version).hexdigest()}"

    @classmethod
    def get_lock_key(cls, organization_id, release_id):
        return f"releasecommits:{organization_id}:{release_id}"

    @classmethod
    def get(cls, project, version):
        cache_key = cls.get_cache_key(project.organization_id, version)

        release = cache.get(cache_key)
        if release is None:
            try:
                release = cls.objects.get(
                    organization_id=project.organization_id,
                    projects=project,
                    version=version)
            except cls.DoesNotExist:
                release = -1
            cache.set(cache_key, release, 300)

        if release == -1:
            return

        return release

    @classmethod
    def get_or_create(cls, project, version, date_added=None):
        with metrics.timer("models.release.get_or_create") as metric_tags:
            return cls._get_or_create_impl(project, version, date_added,
                                           metric_tags)

    @classmethod
    def _get_or_create_impl(cls, project, version, date_added, metric_tags):
        from sentry.models import Project

        if date_added is None:
            date_added = timezone.now()

        cache_key = cls.get_cache_key(project.organization_id, version)

        release = cache.get(cache_key)

        if release in (None, -1):
            # TODO(dcramer): if the cache result is -1 we could attempt a
            # default create here instead of default get
            project_version = (f"{project.slug}-{version}")[:DB_VERSION_LENGTH]
            releases = list(
                cls.objects.filter(
                    organization_id=project.organization_id,
                    version__in=[version, project_version],
                    projects=project,
                ))

            if releases:
                try:
                    release = [
                        r for r in releases if r.version == project_version
                    ][0]
                except IndexError:
                    release = releases[0]
                metric_tags["created"] = "false"
            else:
                try:
                    with atomic_transaction(using=router.db_for_write(cls)):
                        release = cls.objects.create(
                            organization_id=project.organization_id,
                            version=version,
                            date_added=date_added,
                            total_deploys=0,
                        )

                    metric_tags["created"] = "true"
                except IntegrityError:
                    metric_tags["created"] = "false"
                    release = cls.objects.get(
                        organization_id=project.organization_id,
                        version=version)

                release.add_project(project)
                if not project.flags.has_releases:
                    project.flags.has_releases = True
                    project.update(
                        flags=F("flags").bitor(Project.flags.has_releases))

            # TODO(dcramer): upon creating a new release, check if it should be
            # the new "latest release" for this project
            cache.set(cache_key, release, 3600)
            metric_tags["cache_hit"] = "false"
        else:
            metric_tags["cache_hit"] = "true"

        return release

    @cached_property
    def version_info(self):
        try:
            return parse_release(self.version)
        except RelayError:
            # This can happen on invalid legacy releases
            return None

    @classmethod
    def merge(cls, to_release, from_releases):
        # The following models reference release:
        # ReleaseCommit.release
        # ReleaseEnvironment.release_id
        # ReleaseProject.release
        # GroupRelease.release_id
        # GroupResolution.release
        # Group.first_release
        # ReleaseFile.release

        from sentry.models import (
            Group,
            GroupRelease,
            GroupResolution,
            ReleaseCommit,
            ReleaseEnvironment,
            ReleaseFile,
            ReleaseProject,
            ReleaseProjectEnvironment,
        )

        model_list = (
            ReleaseCommit,
            ReleaseEnvironment,
            ReleaseFile,
            ReleaseProject,
            ReleaseProjectEnvironment,
            GroupRelease,
            GroupResolution,
        )
        for release in from_releases:
            for model in model_list:
                if hasattr(model, "release"):
                    update_kwargs = {"release": to_release}
                else:
                    update_kwargs = {"release_id": to_release.id}
                try:
                    with atomic_transaction(using=router.db_for_write(model)):
                        model.objects.filter(release_id=release.id).update(
                            **update_kwargs)
                except IntegrityError:
                    for item in model.objects.filter(release_id=release.id):
                        try:
                            with atomic_transaction(
                                    using=router.db_for_write(model)):
                                model.objects.filter(id=item.id).update(
                                    **update_kwargs)
                        except IntegrityError:
                            item.delete()

            Group.objects.filter(first_release=release).update(
                first_release=to_release)

            release.delete()

    def add_dist(self, name, date_added=None):
        from sentry.models import Distribution

        if date_added is None:
            date_added = timezone.now()
        return Distribution.objects.get_or_create(
            release=self,
            name=name,
            defaults={
                "date_added": date_added,
                "organization_id": self.organization_id
            },
        )[0]

    def add_project(self, project):
        """
        Add a project to this release.

        Returns True if the project was added and did not already exist.
        """
        from sentry.models import Project

        try:
            with atomic_transaction(using=router.db_for_write(ReleaseProject)):
                ReleaseProject.objects.create(project=project, release=self)
                if not project.flags.has_releases:
                    project.flags.has_releases = True
                    project.update(
                        flags=F("flags").bitor(Project.flags.has_releases))
        except IntegrityError:
            return False
        else:
            return True

    def handle_commit_ranges(self, refs):
        """
        Takes commit refs of the form:
        [
            {
                'previousCommit': None,
                'commit': 'previous_commit..commit',
            }
        ]
        Note: Overwrites 'previousCommit' and 'commit'
        """
        for ref in refs:
            if COMMIT_RANGE_DELIMITER in ref["commit"]:
                ref["previousCommit"], ref["commit"] = ref["commit"].split(
                    COMMIT_RANGE_DELIMITER)

    def set_refs(self, refs, user, fetch=False):
        with sentry_sdk.start_span(op="set_refs"):
            from sentry.api.exceptions import InvalidRepository
            from sentry.models import Commit, ReleaseHeadCommit, Repository
            from sentry.tasks.commits import fetch_commits

            # TODO: this does the wrong thing unless you are on the most
            # recent release.  Add a timestamp compare?
            prev_release = (type(self).objects.filter(
                organization_id=self.organization_id,
                projects__in=self.projects.all()).extra(
                    select={
                        "sort": "COALESCE(date_released, date_added)"
                    }).exclude(version=self.version).order_by("-sort").first())

            names = {r["repository"] for r in refs}
            repos = list(
                Repository.objects.filter(organization_id=self.organization_id,
                                          name__in=names))
            repos_by_name = {r.name: r for r in repos}
            invalid_repos = names - set(repos_by_name.keys())
            if invalid_repos:
                raise InvalidRepository(
                    f"Invalid repository names: {','.join(invalid_repos)}")

            self.handle_commit_ranges(refs)

            for ref in refs:
                repo = repos_by_name[ref["repository"]]

                commit = Commit.objects.get_or_create(
                    organization_id=self.organization_id,
                    repository_id=repo.id,
                    key=ref["commit"])[0]
                # update head commit for repo/release if exists
                ReleaseHeadCommit.objects.create_or_update(
                    organization_id=self.organization_id,
                    repository_id=repo.id,
                    release=self,
                    values={"commit": commit},
                )
            if fetch:
                fetch_commits.apply_async(
                    kwargs={
                        "release_id": self.id,
                        "user_id": user.id,
                        "refs": refs,
                        "prev_release_id": prev_release and prev_release.id,
                    })

    def set_commits(self, commit_list):
        """
        Bind a list of commits to this release.

        This will clear any existing commit log and replace it with the given
        commits.
        """

        # Sort commit list in reverse order
        commit_list.sort(key=lambda commit: commit.get("timestamp", 0),
                         reverse=True)

        # TODO(dcramer): this function could use some cleanup/refactoring as it's a bit unwieldy
        from sentry.models import (
            Commit,
            CommitAuthor,
            Group,
            GroupLink,
            GroupResolution,
            GroupStatus,
            PullRequest,
            ReleaseCommit,
            ReleaseHeadCommit,
            Repository,
        )
        from sentry.plugins.providers.repository import RepositoryProvider
        from sentry.tasks.integrations import kick_off_status_syncs

        # todo(meredith): implement for IntegrationRepositoryProvider
        commit_list = [
            c for c in commit_list if
            not RepositoryProvider.should_ignore_commit(c.get("message", ""))
        ]
        lock_key = type(self).get_lock_key(self.organization_id, self.id)
        lock = locks.get(lock_key, duration=10)
        if lock.locked():
            # Signal failure to the consumer rapidly. This aims to prevent the number
            # of timeouts and prevent web worker exhaustion when customers create
            # the same release rapidly for different projects.
            raise ReleaseCommitError
        with TimedRetryPolicy(10)(lock.acquire):
            start = time()
            with atomic_transaction(using=(
                    router.db_for_write(type(self)),
                    router.db_for_write(ReleaseCommit),
                    router.db_for_write(Repository),
                    router.db_for_write(CommitAuthor),
                    router.db_for_write(Commit),
            )):
                # TODO(dcramer): would be good to optimize the logic to avoid these
                # deletes but not overly important
                ReleaseCommit.objects.filter(release=self).delete()

                authors = {}
                repos = {}
                commit_author_by_commit = {}
                head_commit_by_repo = {}
                latest_commit = None
                for idx, data in enumerate(commit_list):
                    repo_name = data.get(
                        "repository") or f"organization-{self.organization_id}"
                    if repo_name not in repos:
                        repos[
                            repo_name] = repo = Repository.objects.get_or_create(
                                organization_id=self.organization_id,
                                name=repo_name)[0]
                    else:
                        repo = repos[repo_name]

                    author_email = data.get("author_email")
                    if author_email is None and data.get("author_name"):
                        author_email = (re.sub(r"[^a-zA-Z0-9\-_\.]*", "",
                                               data["author_name"]).lower() +
                                        "@localhost")

                    author_email = truncatechars(author_email, 75)

                    if not author_email:
                        author = None
                    elif author_email not in authors:
                        author_data = {"name": data.get("author_name")}
                        author, created = CommitAuthor.objects.get_or_create(
                            organization_id=self.organization_id,
                            email=author_email,
                            defaults=author_data,
                        )
                        if author.name != author_data["name"]:
                            author.update(name=author_data["name"])
                        authors[author_email] = author
                    else:
                        author = authors[author_email]

                    commit_data = {}

                    # Update/set message and author if they are provided.
                    if author is not None:
                        commit_data["author"] = author
                    if "message" in data:
                        commit_data["message"] = data["message"]
                    if "timestamp" in data:
                        commit_data["date_added"] = data["timestamp"]

                    commit, created = Commit.objects.get_or_create(
                        organization_id=self.organization_id,
                        repository_id=repo.id,
                        key=data["id"],
                        defaults=commit_data,
                    )
                    if not created:
                        commit_data = {
                            key: value
                            for key, value in commit_data.items()
                            if getattr(commit, key) != value
                        }
                        if commit_data:
                            commit.update(**commit_data)

                    if author is None:
                        author = commit.author

                    commit_author_by_commit[commit.id] = author

                    # Guard against patch_set being None
                    patch_set = data.get("patch_set") or []
                    if patch_set:
                        CommitFileChange.objects.bulk_create(
                            [
                                CommitFileChange(
                                    organization_id=self.organization.id,
                                    commit=commit,
                                    filename=patched_file["path"],
                                    type=patched_file["type"],
                                ) for patched_file in patch_set
                            ],
                            ignore_conflicts=True,
                        )

                    try:
                        with atomic_transaction(
                                using=router.db_for_write(ReleaseCommit)):
                            ReleaseCommit.objects.create(
                                organization_id=self.organization_id,
                                release=self,
                                commit=commit,
                                order=idx,
                            )
                    except IntegrityError:
                        pass

                    if latest_commit is None:
                        latest_commit = commit

                    head_commit_by_repo.setdefault(repo.id, commit.id)

                self.update(
                    commit_count=len(commit_list),
                    authors=[
                        str(a_id) for a_id in ReleaseCommit.objects.filter(
                            release=self, commit__author_id__isnull=False).
                        values_list("commit__author_id", flat=True).distinct()
                    ],
                    last_commit_id=latest_commit.id if latest_commit else None,
                )
                metrics.timing("release.set_commits.duration", time() - start)

        # fill any missing ReleaseHeadCommit entries
        for repo_id, commit_id in head_commit_by_repo.items():
            try:
                with atomic_transaction(
                        using=router.db_for_write(ReleaseHeadCommit)):
                    ReleaseHeadCommit.objects.create(
                        organization_id=self.organization_id,
                        release_id=self.id,
                        repository_id=repo_id,
                        commit_id=commit_id,
                    )
            except IntegrityError:
                pass

        release_commits = list(
            ReleaseCommit.objects.filter(
                release=self).select_related("commit").values(
                    "commit_id", "commit__key"))

        commit_resolutions = list(
            GroupLink.objects.filter(
                linked_type=GroupLink.LinkedType.commit,
                linked_id__in=[rc["commit_id"] for rc in release_commits],
            ).values_list("group_id", "linked_id"))

        commit_group_authors = [
            (cr[0], commit_author_by_commit.get(cr[1]))
            for cr in commit_resolutions  # group_id
        ]

        pr_ids_by_merge_commit = list(
            PullRequest.objects.filter(
                merge_commit_sha__in=[
                    rc["commit__key"] for rc in release_commits
                ],
                organization_id=self.organization_id,
            ).values_list("id", flat=True))

        pull_request_resolutions = list(
            GroupLink.objects.filter(
                relationship=GroupLink.Relationship.resolves,
                linked_type=GroupLink.LinkedType.pull_request,
                linked_id__in=pr_ids_by_merge_commit,
            ).values_list("group_id", "linked_id"))

        pr_authors = list(
            PullRequest.objects.filter(
                id__in=[prr[1] for prr in pull_request_resolutions
                        ]).select_related("author"))

        pr_authors_dict = {pra.id: pra.author for pra in pr_authors}

        pull_request_group_authors = [(prr[0], pr_authors_dict.get(prr[1]))
                                      for prr in pull_request_resolutions]

        user_by_author = {None: None}

        commits_and_prs = list(
            itertools.chain(commit_group_authors, pull_request_group_authors))

        group_project_lookup = dict(
            Group.objects.filter(
                id__in=[group_id
                        for group_id, _ in commits_and_prs]).values_list(
                            "id", "project_id"))

        for group_id, author in commits_and_prs:
            if author not in user_by_author:
                try:
                    user_by_author[author] = author.find_users()[0]
                except IndexError:
                    user_by_author[author] = None
            actor = user_by_author[author]

            with atomic_transaction(using=(
                    router.db_for_write(GroupResolution),
                    router.db_for_write(Group),
                    # inside the remove_group_from_inbox
                    router.db_for_write(GroupInbox),
                    router.db_for_write(Activity),
            )):
                GroupResolution.objects.create_or_update(
                    group_id=group_id,
                    values={
                        "release": self,
                        "type": GroupResolution.Type.in_release,
                        "status": GroupResolution.Status.resolved,
                        "actor_id": actor.id if actor else None,
                    },
                )
                group = Group.objects.get(id=group_id)
                group.update(status=GroupStatus.RESOLVED)
                remove_group_from_inbox(group,
                                        action=GroupInboxRemoveAction.RESOLVED,
                                        user=actor)
                record_group_history(group,
                                     GroupHistoryStatus.RESOLVED,
                                     actor=actor)

                metrics.incr("group.resolved",
                             instance="in_commit",
                             skip_internal=True)

            issue_resolved.send_robust(
                organization_id=self.organization_id,
                user=actor,
                group=group,
                project=group.project,
                resolution_type="with_commit",
                sender=type(self),
            )

            kick_off_status_syncs.apply_async(
                kwargs={
                    "project_id": group_project_lookup[group_id],
                    "group_id": group_id
                })

    def safe_delete(self):
        """Deletes a release if possible or raises a `UnsafeReleaseDeletion`
        exception.
        """
        from sentry import release_health
        from sentry.models import Group, ReleaseFile

        # we don't want to remove the first_release metadata on the Group, and
        # while people might want to kill a release (maybe to remove files),
        # removing the release is prevented
        if Group.objects.filter(first_release=self).exists():
            raise UnsafeReleaseDeletion(ERR_RELEASE_REFERENCED)

        # We do not allow releases with health data to be deleted because
        # the upserting from snuba data would create the release again.
        # We would need to be able to delete this data from snuba which we
        # can't do yet.
        project_ids = list(self.projects.values_list("id").all())
        if release_health.check_has_health_data([(p[0], self.version)
                                                 for p in project_ids]):
            raise UnsafeReleaseDeletion(ERR_RELEASE_HEALTH_DATA)

        # TODO(dcramer): this needs to happen in the queue as it could be a long
        # and expensive operation
        file_list = ReleaseFile.objects.filter(
            release_id=self.id).select_related("file")
        for releasefile in file_list:
            releasefile.file.delete()
            releasefile.delete()
        self.delete()

    def count_artifacts(self):
        """Sum the artifact_counts of all release files.

        An artifact count of NULL is interpreted as 1.
        """
        counts = get_artifact_counts([self.id])
        return counts.get(self.id, 0)

    def clear_commits(self):
        """
        Delete all release-specific commit data associated to this release. We will not delete the Commit model values because other releases may use these commits.
        """
        with sentry_sdk.start_span(op="clear_commits"):
            from sentry.models import ReleaseCommit, ReleaseHeadCommit

            ReleaseHeadCommit.objects.get(organization_id=self.organization_id,
                                          release=self).delete()
            ReleaseCommit.objects.filter(organization_id=self.organization_id,
                                         release=self).delete()

            self.authors = []
            self.commit_count = 0
            self.last_commit_id = None
            self.save()
Esempio n. 15
0
class TagValue(Model):
    """
    Stores references to available filters.
    """
    __core__ = False

    project_id = BoundedBigIntegerField(db_index=True)
    _key = FlexibleForeignKey('tagstore.TagKey', db_column='key_id')
    value = models.CharField(max_length=MAX_TAG_VALUE_LENGTH)
    data = GzippedDictField(blank=True, null=True)
    times_seen = BoundedPositiveIntegerField(default=0)
    last_seen = models.DateTimeField(
        default=timezone.now, db_index=True, null=True)
    first_seen = models.DateTimeField(
        default=timezone.now, db_index=True, null=True)

    objects = TagStoreManager()

    class Meta:
        app_label = 'tagstore'
        unique_together = (('project_id', '_key', 'value'), )
        index_together = (('project_id', '_key', 'last_seen'), )

    __repr__ = sane_repr('project_id', '_key_id', 'value')

    def delete(self):
        using = router.db_for_read(TagValue)
        cursor = connections[using].cursor()
        cursor.execute(
            """
            DELETE FROM tagstore_tagvalue
            WHERE project_id = %s
              AND id = %s
        """, [self.project_id, self.id]
        )

    @property
    def key(self):
        if hasattr(self, '_set_key'):
            return self._set_key

        if hasattr(self, '__key_cache'):
            return self._key.key

        # fallback
        from sentry.tagstore.v2.models import TagKey

        tk = TagKey.objects.filter(
            project_id=self.project_id,
            id=self._key_id,
        ).values_list('key', flat=True).get()

        # cache for future calls
        self.key = tk

        return tk

    @key.setter
    def key(self, key):
        self._set_key = key

    def get_label(self):
        from sentry import tagstore

        return tagstore.get_tag_value_label(self.key, self.value)

    @classmethod
    def get_cache_key(cls, project_id, _key_id, value):
        return 'tagvalue:1:%s:%s:%s' % (project_id, _key_id, md5_text(value).hexdigest())

    @classmethod
    def get_or_create(cls, project_id, _key_id, value, **kwargs):
        cache_key = cls.get_cache_key(project_id, _key_id, value)

        rv = cache.get(cache_key)
        created = False
        if rv is None:
            rv, created = cls.objects.get_or_create(
                project_id=project_id,
                _key_id=_key_id,
                value=value,
                **kwargs
            )
            cache.set(cache_key, rv, 3600)

        return rv, created

    @classmethod
    def get_or_create_bulk(cls, project_id, tags):
        # Attempt to create a bunch of models in one big batch with as few
        # queries and cache calls as possible.
        # In best case, this is all done in 1 cache get.
        # If we miss cache hit here, we have to fall back to old behavior.
        key_to_model = {tag: None for tag in tags}
        tags_by_key_id = {tag[0].id: tag for tag in tags}
        remaining_keys = set(tags)

        # First attempt to hit from cache, which in theory is the hot case
        cache_key_to_key = {cls.get_cache_key(project_id, tk.id, v): (tk, v) for tk, v in tags}
        cache_key_to_models = cache.get_many(cache_key_to_key.keys())
        for model in cache_key_to_models.values():
            key_to_model[tags_by_key_id[model._key_id]] = model
            remaining_keys.remove(tags_by_key_id[model._key_id])

        if not remaining_keys:
            # 100% cache hit on all items, good work team
            return key_to_model

        # Fall back to just doing it manually
        # Further optimizations start to become not so great.
        # For some reason, when trying to do a bulk SELECT with all of the
        # key value pairs in big OR ends up using the wrong index and ultimating
        # generating a significantly less efficient query. The only alternative is to
        # splice this up a bit and do all of the SELECTs, then do a bulk INSERT for remaining
        for key in remaining_keys:
            key_to_model[key] = cls.get_or_create(project_id, key[0].id, key[1])[0]

        return key_to_model
class Group(Model):
    """
    Aggregated message which summarizes a set of Events.
    """
    __core__ = False

    project = FlexibleForeignKey('sentry.Project', null=True)
    logger = models.CharField(max_length=64,
                              blank=True,
                              default=DEFAULT_LOGGER_NAME,
                              db_index=True)
    level = BoundedPositiveIntegerField(choices=LOG_LEVELS.items(),
                                        default=logging.ERROR,
                                        blank=True,
                                        db_index=True)
    message = models.TextField()
    culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH,
                               blank=True,
                               null=True,
                               db_column='view')
    num_comments = BoundedPositiveIntegerField(default=0, null=True)
    platform = models.CharField(max_length=64, null=True)
    status = BoundedPositiveIntegerField(default=0,
                                         choices=(
                                             (GroupStatus.UNRESOLVED,
                                              _('Unresolved')),
                                             (GroupStatus.RESOLVED,
                                              _('Resolved')),
                                             (GroupStatus.IGNORED,
                                              _('Ignored')),
                                         ),
                                         db_index=True)
    times_seen = BoundedPositiveIntegerField(default=1, db_index=True)
    last_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_release = FlexibleForeignKey('sentry.Release',
                                       null=True,
                                       on_delete=models.PROTECT)
    resolved_at = models.DateTimeField(null=True, db_index=True)
    # active_at should be the same as first_seen by default
    active_at = models.DateTimeField(null=True, db_index=True)
    time_spent_total = BoundedIntegerField(default=0)
    time_spent_count = BoundedIntegerField(default=0)
    score = BoundedIntegerField(default=0)
    # deprecated, do not use. GroupShare has superseded
    is_public = models.NullBooleanField(default=False, null=True)
    data = GzippedDictField(blank=True, null=True)
    short_id = BoundedBigIntegerField(null=True)

    objects = GroupManager()

    class Meta:
        app_label = 'sentry'
        db_table = 'sentry_groupedmessage'
        verbose_name_plural = _('grouped messages')
        verbose_name = _('grouped message')
        permissions = (("can_view", "Can view"), )
        index_together = (('project', 'first_release'), )
        unique_together = (('project', 'short_id'), )

    __repr__ = sane_repr('project_id')

    def __unicode__(self):
        return "(%s) %s" % (self.times_seen, self.error())

    def save(self, *args, **kwargs):
        if not self.last_seen:
            self.last_seen = timezone.now()
        if not self.first_seen:
            self.first_seen = self.last_seen
        if not self.active_at:
            self.active_at = self.first_seen
        # We limit what we store for the message body
        self.message = strip(self.message)
        if self.message:
            self.message = truncatechars(self.message.splitlines()[0], 255)
        super(Group, self).save(*args, **kwargs)

    def get_absolute_url(self):
        return absolute_uri(
            reverse('sentry-group',
                    args=[self.organization.slug, self.project.slug, self.id]))

    @property
    def qualified_short_id(self):
        if self.short_id is not None:
            return '%s-%s' % (
                self.project.slug.upper(),
                base32_encode(self.short_id),
            )

    @property
    def event_set(self):
        from sentry.models import Event
        return Event.objects.filter(group_id=self.id)

    def is_over_resolve_age(self):
        resolve_age = self.project.get_option('sentry:resolve_age', None)
        if not resolve_age:
            return False
        return self.last_seen < timezone.now() - timedelta(
            hours=int(resolve_age))

    def is_ignored(self):
        return self.get_status() == GroupStatus.IGNORED

    # TODO(dcramer): remove in 9.0 / after plugins no long ref
    is_muted = is_ignored

    def is_resolved(self):
        return self.get_status() == GroupStatus.RESOLVED

    def get_status(self):
        # XXX(dcramer): GroupSerializer reimplements this logic
        from sentry.models import GroupSnooze

        status = self.status

        if status == GroupStatus.IGNORED:
            try:
                snooze = GroupSnooze.objects.get(group=self)
            except GroupSnooze.DoesNotExist:
                pass
            else:
                if not snooze.is_valid(group=self):
                    status = GroupStatus.UNRESOLVED

        if status == GroupStatus.UNRESOLVED and self.is_over_resolve_age():
            return GroupStatus.RESOLVED
        return status

    def get_share_id(self):
        from sentry.models import GroupShare
        try:
            return GroupShare.objects.filter(group_id=self.id, ).values_list(
                'uuid', flat=True)[0]
        except IndexError:
            # Otherwise it has not been shared yet.
            return None

    @classmethod
    def from_share_id(cls, share_id):
        if not share_id or len(share_id) != 32:
            raise cls.DoesNotExist

        from sentry.models import GroupShare
        return cls.objects.get(id=GroupShare.objects.filter(
            uuid=share_id, ).values_list('group_id'), )

    def get_score(self):
        return int(
            math.log(self.times_seen) * 600 +
            float(time.mktime(self.last_seen.timetuple())))

    def get_latest_event(self):
        from sentry.models import Event

        if not hasattr(self, '_latest_event'):
            latest_events = sorted(
                Event.objects.filter(
                    group_id=self.id, ).order_by('-datetime')[0:5],
                key=EVENT_ORDERING_KEY,
                reverse=True,
            )
            try:
                self._latest_event = latest_events[0]
            except IndexError:
                self._latest_event = None
        return self._latest_event

    def get_oldest_event(self):
        from sentry.models import Event

        if not hasattr(self, '_oldest_event'):
            oldest_events = sorted(
                Event.objects.filter(
                    group_id=self.id, ).order_by('datetime')[0:5],
                key=EVENT_ORDERING_KEY,
            )
            try:
                self._oldest_event = oldest_events[0]
            except IndexError:
                self._oldest_event = None
        return self._oldest_event

    def get_first_release(self):
        if self.first_release_id is None:
            return tagstore.get_first_release(self.project_id, self.id)

        return self.first_release.version

    def get_last_release(self):
        return tagstore.get_last_release(self.project_id, self.id)

    def get_event_type(self):
        """
        Return the type of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data.get('type', 'default')

    def get_event_metadata(self):
        """
        Return the metadata of this issue.

        See ``sentry.eventtypes``.
        """
        etype = self.data.get('type')
        if etype is None:
            etype = 'default'
        if 'metadata' not in self.data:
            data = self.data.copy() if self.data else {}
            data['message'] = self.message
            return eventtypes.get(etype)(data).get_metadata()
        return self.data['metadata']

    @property
    def title(self):
        et = eventtypes.get(self.get_event_type())(self.data)
        return et.to_string(self.get_event_metadata())

    def error(self):
        warnings.warn('Group.error is deprecated, use Group.title',
                      DeprecationWarning)
        return self.title

    error.short_description = _('error')

    @property
    def message_short(self):
        warnings.warn('Group.message_short is deprecated, use Group.title',
                      DeprecationWarning)
        return self.title

    @property
    def organization(self):
        return self.project.organization

    @property
    def checksum(self):
        warnings.warn('Group.checksum is no longer used', DeprecationWarning)
        return ''

    def get_email_subject(self):
        return '%s - %s' % (self.qualified_short_id.encode('utf-8'),
                            self.title.encode('utf-8'))

    def count_users_seen(self):
        return tagstore.get_groups_user_counts(self.project_id, [self.id],
                                               environment_id=None)[self.id]
Esempio n. 17
0
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,
    )

    objects = BaseManager()

    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')

    # 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):
        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

    def _get_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)

    def get_legacy_message(self):
        msg_interface = self.data.get('sentry.interfaces.Message', {
            'message': self.message,
        })
        return msg_interface.get('formatted', msg_interface['message'])

    def get_event_type(self):
        """
        Return the type of this event.

        See ``sentry.eventtypes``.
        """
        return self.data.get('type', 'default')

    def get_event_metadata(self):
        """
        Return the metadata of this event.

        See ``sentry.eventtypes``.
        """
        etype = self.data.get('type', 'default')
        if 'metadata' not in self.data:
            # TODO(dcramer): remove after Dec 1 2016
            data = self.data.copy() if self.data else {}
            data['message'] = self.message
            return eventtypes.get(etype)(data).get_metadata()
        return self.data['metadata']

    @property
    def title(self):
        et = eventtypes.get(self.get_event_type())(self.data)
        return et.to_string(self.get_event_metadata())

    def error(self):
        warnings.warn('Event.error is deprecated, use Event.title',
                      DeprecationWarning)
        return self.title

    error.short_description = _('error')

    @property
    def message_short(self):
        warnings.warn('Event.message_short is deprecated, use Event.title',
                      DeprecationWarning)
        return self.title

    def has_two_part_message(self):
        warnings.warn('Event.has_two_part_message is no longer used',
                      DeprecationWarning)
        return False

    @property
    def team(self):
        return self.project.team

    @property
    def organization(self):
        return self.project.organization

    @property
    def version(self):
        return self.data.get('version', '5')

    @memoize
    def ip_address(self):
        user_data = self.data.get('sentry.interfaces.User',
                                  self.data.get('user'))
        if user_data:
            value = user_data.get('ip_address')
            if value:
                return value

        http_data = self.data.get('sentry.interfaces.Http',
                                  self.data.get('http'))
        if http_data and 'env' in http_data:
            value = http_data['env'].get('REMOTE_ADDR')
            if value:
                return value

        return None

    def get_interfaces(self):
        result = []
        for key, data in six.iteritems(self.data):
            try:
                cls = get_interface(key)
            except ValueError:
                continue

            value = safe_execute(cls.to_python, data, _with_transaction=False)
            if not value:
                continue

            result.append((key, value))

        return OrderedDict((k, v) for k, v in sorted(
            result, key=lambda x: x[1].get_score(), reverse=True))

    @memoize
    def interfaces(self):
        return self.get_interfaces()

    def get_tags(self, with_internal=True):
        try:
            return sorted((t, v) for t, v in self.data.get('tags') or ()
                          if with_internal or not t.startswith('sentry:'))
        except ValueError:
            # at one point Sentry allowed invalid tag sets such as (foo, bar)
            # vs ((tag, foo), (tag, bar))
            return []

    tags = property(get_tags)

    def get_tag(self, key):
        for t, v in (self.data.get('tags') or ()):
            if t == key:
                return v
        return None

    @property
    def dist(self):
        return self.get_tag('sentry:dist')

    def as_dict(self):
        # We use a OrderedDict to keep elements ordered for a potential JSON serializer
        data = OrderedDict()
        data['id'] = self.event_id
        data['project'] = self.project_id
        data['release'] = self.get_tag('sentry:release')
        data['dist'] = self.dist
        data['platform'] = self.platform
        data['culprit'] = self.group.culprit
        data['message'] = self.get_legacy_message()
        data['datetime'] = self.datetime
        data['time_spent'] = self.time_spent
        data['tags'] = self.get_tags()
        for k, v in sorted(six.iteritems(self.data)):
            data[k] = v
        return data

    @property
    def size(self):
        data_len = len(self.get_legacy_message())
        for value in six.itervalues(self.data):
            data_len += len(repr(value))
        return data_len

    # XXX(dcramer): compatibility with plugins
    def get_level_display(self):
        warnings.warn(
            'Event.get_level_display is deprecated. Use Event.tags instead.',
            DeprecationWarning)
        return self.group.get_level_display()

    @property
    def level(self):
        warnings.warn('Event.level is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.group.level

    @property
    def logger(self):
        warnings.warn('Event.logger is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.get_tag('logger')

    @property
    def site(self):
        warnings.warn('Event.site is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.get_tag('site')

    @property
    def server_name(self):
        warnings.warn(
            'Event.server_name is deprecated. Use Event.tags instead.')
        return self.get_tag('server_name')

    @property
    def culprit(self):
        warnings.warn(
            'Event.culprit is deprecated. Use Group.culprit instead.')
        return self.transaction or self.group.culprit

    @property
    def checksum(self):
        warnings.warn('Event.checksum is no longer used', DeprecationWarning)
        return ''

    @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')
Esempio n. 18
0
class Group(Model):
    """
    Aggregated message which summarizes a set of Events.
    """

    __include_in_export__ = False

    project = FlexibleForeignKey("sentry.Project")
    logger = models.CharField(max_length=64,
                              blank=True,
                              default=str(DEFAULT_LOGGER_NAME),
                              db_index=True)
    level = BoundedPositiveIntegerField(
        choices=[(key, str(val)) for key, val in sorted(LOG_LEVELS.items())],
        default=logging.ERROR,
        blank=True,
        db_index=True,
    )
    message = models.TextField()
    culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH,
                               blank=True,
                               null=True,
                               db_column="view")
    num_comments = BoundedPositiveIntegerField(default=0, null=True)
    platform = models.CharField(max_length=64, null=True)
    status = BoundedPositiveIntegerField(
        default=0,
        choices=(
            (GroupStatus.UNRESOLVED, _("Unresolved")),
            (GroupStatus.RESOLVED, _("Resolved")),
            (GroupStatus.IGNORED, _("Ignored")),
        ),
        db_index=True,
    )
    times_seen = BoundedPositiveIntegerField(default=1, db_index=True)
    last_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_seen = models.DateTimeField(default=timezone.now, db_index=True)
    first_release = FlexibleForeignKey("sentry.Release",
                                       null=True,
                                       on_delete=models.PROTECT)
    resolved_at = models.DateTimeField(null=True, db_index=True)
    # active_at should be the same as first_seen by default
    active_at = models.DateTimeField(null=True, db_index=True)
    time_spent_total = BoundedIntegerField(default=0)
    time_spent_count = BoundedIntegerField(default=0)
    score = BoundedIntegerField(default=0)
    # deprecated, do not use. GroupShare has superseded
    is_public = models.NullBooleanField(default=False, null=True)
    data = GzippedDictField(blank=True, null=True)
    short_id = BoundedBigIntegerField(null=True)

    objects = GroupManager(cache_fields=("id", ))

    class Meta:
        app_label = "sentry"
        db_table = "sentry_groupedmessage"
        verbose_name_plural = _("grouped messages")
        verbose_name = _("grouped message")
        permissions = (("can_view", "Can view"), )
        index_together = [
            ("project", "first_release"),
            ("project", "id"),
            ("project", "status", "last_seen", "id"),
        ]
        unique_together = (
            ("project", "short_id"),
            ("project", "id"),
        )

    __repr__ = sane_repr("project_id")

    def __str__(self):
        return f"({self.times_seen}) {self.error()}"

    def save(self, *args, **kwargs):
        if not self.last_seen:
            self.last_seen = timezone.now()
        if not self.first_seen:
            self.first_seen = self.last_seen
        if not self.active_at:
            self.active_at = self.first_seen
        # We limit what we store for the message body
        self.message = strip(self.message)
        if self.message:
            self.message = truncatechars(self.message.splitlines()[0], 255)
        if self.times_seen is None:
            self.times_seen = 1
        self.score = type(self).calculate_score(times_seen=self.times_seen,
                                                last_seen=self.last_seen)
        super().save(*args, **kwargs)

    def get_absolute_url(
        self,
        params: Mapping[str, str] | None = None,
        event_id: int | None = None,
        organization_slug: str | None = None,
    ) -> str:
        # Built manually in preference to django.urls.reverse,
        # because reverse has a measured performance impact.
        event_path = f"events/{event_id}/" if event_id else ""
        url = "organizations/{org}/issues/{id}/{event_path}{params}".format(
            # Pass organization_slug if this needs to be called multiple times to avoid n+1 queries
            org=urlquote(self.organization.slug
                         if organization_slug is None else organization_slug),
            id=self.id,
            event_path=event_path,
            params="?" + urlencode(params) if params else "",
        )
        return absolute_uri(url)

    @property
    def qualified_short_id(self):
        if self.short_id is not None:
            return f"{self.project.slug.upper()}-{base32_encode(self.short_id)}"

    def is_over_resolve_age(self):
        resolve_age = self.project.get_option("sentry:resolve_age", None)
        if not resolve_age:
            return False
        return self.last_seen < timezone.now() - timedelta(
            hours=int(resolve_age))

    def is_ignored(self):
        return self.get_status() == GroupStatus.IGNORED

    def is_unresolved(self):
        return self.get_status() == GroupStatus.UNRESOLVED

    # TODO(dcramer): remove in 9.0 / after plugins no long ref
    is_muted = is_ignored

    def is_resolved(self):
        return self.get_status() == GroupStatus.RESOLVED

    def get_status(self):
        # XXX(dcramer): GroupSerializer reimplements this logic
        from sentry.models import GroupSnooze

        status = self.status

        if status == GroupStatus.IGNORED:
            try:
                snooze = GroupSnooze.objects.get_from_cache(group=self)
            except GroupSnooze.DoesNotExist:
                pass
            else:
                if not snooze.is_valid(group=self):
                    status = GroupStatus.UNRESOLVED

        if status == GroupStatus.UNRESOLVED and self.is_over_resolve_age():
            return GroupStatus.RESOLVED
        return status

    def get_share_id(self):
        from sentry.models import GroupShare

        try:
            return GroupShare.objects.filter(group_id=self.id).values_list(
                "uuid", flat=True)[0]
        except IndexError:
            # Otherwise it has not been shared yet.
            return None

    def get_score(self):
        return type(self).calculate_score(self.times_seen, self.last_seen)

    def get_latest_event(self) -> Event | None:
        if not hasattr(self, "_latest_event"):
            self._latest_event = self.get_latest_event_for_environments()

        return self._latest_event

    def get_latest_event_for_environments(self, environments=()):
        return get_oldest_or_latest_event_for_environments(
            EventOrdering.LATEST,
            environments=environments,
            issue_id=self.id,
            project_id=self.project_id,
        )

    def get_oldest_event_for_environments(self, environments=()):
        return get_oldest_or_latest_event_for_environments(
            EventOrdering.OLDEST,
            environments=environments,
            issue_id=self.id,
            project_id=self.project_id,
        )

    def _get_cache_key(self, project_id, group_id, first):
        return f"g-r:{group_id}-{project_id}-{first}"

    def __get_release(self, project_id, group_id, first=True, use_cache=True):
        from sentry.models import GroupRelease, Release

        orderby = "first_seen" if first else "-last_seen"
        cache_key = self._get_cache_key(project_id, group_id, first)
        try:
            release_version = cache.get(cache_key) if use_cache else None
            if release_version is None:
                release_version = Release.objects.get(
                    id__in=GroupRelease.objects.filter(group_id=group_id).
                    order_by(orderby).values("release_id")[:1]).version
                cache.set(cache_key, release_version, 3600)
            elif release_version is False:
                release_version = None
            return release_version
        except Release.DoesNotExist:
            cache.set(cache_key, False, 3600)
            return None

    def get_first_release(self):
        if self.first_release_id is None:
            first_release = self.__get_release(self.project_id, self.id, True)
            return first_release

        return self.first_release.version

    def get_last_release(self, use_cache=True):
        return self.__get_release(self.project_id,
                                  self.id,
                                  False,
                                  use_cache=use_cache)

    def get_event_type(self):
        """
        Return the type of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data.get("type", "default")

    def get_event_metadata(self) -> Mapping[str, str]:
        """
        Return the metadata of this issue.

        See ``sentry.eventtypes``.
        """
        return self.data["metadata"]

    @property
    def title(self) -> str:
        et = eventtypes.get(self.get_event_type())()
        return et.get_title(self.get_event_metadata())

    def location(self):
        et = eventtypes.get(self.get_event_type())()
        return et.get_location(self.get_event_metadata())

    def error(self):
        warnings.warn("Group.error is deprecated, use Group.title",
                      DeprecationWarning)
        return self.title

    error.short_description = _("error")

    @property
    def message_short(self):
        warnings.warn("Group.message_short is deprecated, use Group.title",
                      DeprecationWarning)
        return self.title

    @property
    def organization(self):
        return self.project.organization

    @property
    def checksum(self):
        warnings.warn("Group.checksum is no longer used", DeprecationWarning)
        return ""

    def get_email_subject(self):
        return f"{self.qualified_short_id} - {self.title}"

    def count_users_seen(self):
        return tagstore.get_groups_user_counts([self.project_id], [self.id],
                                               environment_ids=None,
                                               start=self.first_seen)[self.id]

    @classmethod
    def calculate_score(cls, times_seen, last_seen):
        return math.log(float(times_seen or 1)) * 600 + float(
            last_seen.strftime("%s"))

    @staticmethod
    def issues_mapping(group_ids, project_ids, organization):
        """Create a dictionary of group_ids to their qualified_short_ids"""
        return {
            i.id: i.qualified_short_id
            for i in Group.objects.filter(id__in=group_ids,
                                          project_id__in=project_ids,
                                          project__organization=organization)
        }

    def get_assignee(self) -> Team | User | None:
        from sentry.models import GroupAssignee

        try:
            group_assignee = GroupAssignee.objects.get(group=self)
        except GroupAssignee.DoesNotExist:
            return None

        assigned_actor = group_assignee.assigned_actor()

        try:
            return assigned_actor.resolve()
        except assigned_actor.type.DoesNotExist:
            return None
class NotificationSetting(Model):
    """
    A setting of when to notify a user or team about activity within the app.
    Each row is a notification setting where a key is:
    ("scope_type", "scope_identifier", "target", "provider", "type"),
    and the value is ("value").
    """

    __core__ = False

    scope_type = BoundedPositiveIntegerField(
        choices=(
            (NotificationScopeType.USER, "user"),
            (NotificationScopeType.ORGANIZATION, "organization"),
            (NotificationScopeType.PROJECT, "project"),
        ),
        null=False,
    )
    # user_id, organization_id, project_id
    scope_identifier = BoundedBigIntegerField(null=False)
    target = FlexibleForeignKey(
        "sentry.Actor", db_index=True, unique=False, null=False, on_delete=models.CASCADE
    )
    provider = BoundedPositiveIntegerField(
        choices=(
            (ExternalProviders.EMAIL, "email"),
            (ExternalProviders.SLACK, "slack"),
        ),
        null=False,
    )
    type = BoundedPositiveIntegerField(
        choices=(
            (NotificationSettingTypes.DEFAULT, "default"),
            (NotificationSettingTypes.DEPLOY, "deploy"),
            (NotificationSettingTypes.ISSUE_ALERTS, "issue"),
            (NotificationSettingTypes.WORKFLOW, "workflow"),
        ),
        null=False,
    )
    value = BoundedPositiveIntegerField(
        choices=(
            (NotificationSettingOptionValues.DEFAULT, "default"),
            (NotificationSettingOptionValues.NEVER, "off"),
            (NotificationSettingOptionValues.ALWAYS, "on"),
            (NotificationSettingOptionValues.SUBSCRIBE_ONLY, "subscribe_only"),
            (NotificationSettingOptionValues.COMMITTED_ONLY, "committed_only"),
        ),
        null=False,
    )

    objects = NotificationsManager()

    class Meta:
        app_label = "sentry"
        db_table = "sentry_notificationsetting"
        unique_together = (
            (
                "scope_type",
                "scope_identifier",
                "target",
                "provider",
                "type",
            ),
        )

    __repr__ = sane_repr(
        "scope_type",
        "scope_identifier",
        "target",
        "provider",
        "type",
        "value",
    )
class OrganizationOnboardingTask(Model):
    """
    Onboarding tasks walk new Sentry orgs through basic features of Sentry.
    """

    __core__ = False

    TASK_CHOICES = (
        (OnboardingTask.FIRST_PROJECT, "create_project"),
        (OnboardingTask.FIRST_EVENT, "send_first_event"),
        (OnboardingTask.INVITE_MEMBER, "invite_member"),
        (OnboardingTask.SECOND_PLATFORM, "setup_second_platform"),
        (OnboardingTask.USER_CONTEXT, "setup_user_context"),
        (OnboardingTask.RELEASE_TRACKING, "setup_release_tracking"),
        (OnboardingTask.SOURCEMAPS, "setup_sourcemaps"),
        (OnboardingTask.USER_REPORTS, "setup_user_reports"),
        (OnboardingTask.ISSUE_TRACKER, "setup_issue_tracker"),
        (OnboardingTask.ALERT_RULE, "setup_alert_rules"),
    )

    STATUS_CHOICES = (
        (OnboardingTaskStatus.COMPLETE, "complete"),
        (OnboardingTaskStatus.PENDING, "pending"),
        (OnboardingTaskStatus.SKIPPED, "skipped"),
    )

    # Used in the API to map IDs to string keys. This keeps things
    # a bit more maintainable on the frontend.
    TASK_KEY_MAP = dict(TASK_CHOICES)
    TASK_LOOKUP_BY_KEY = {v: k for k, v in TASK_CHOICES}

    STATUS_KEY_MAP = dict(STATUS_CHOICES)
    STATUS_LOOKUP_BY_KEY = {v: k for k, v in STATUS_CHOICES}

    # Tasks which must be completed for the onboarding to be considered
    # complete.
    REQUIRED_ONBOARDING_TASKS = frozenset([
        OnboardingTask.FIRST_PROJECT,
        OnboardingTask.FIRST_EVENT,
        OnboardingTask.INVITE_MEMBER,
        OnboardingTask.SECOND_PLATFORM,
        OnboardingTask.USER_CONTEXT,
        OnboardingTask.RELEASE_TRACKING,
        OnboardingTask.SOURCEMAPS,
        OnboardingTask.ISSUE_TRACKER,
        OnboardingTask.ALERT_RULE,
    ])

    SKIPPABLE_TASKS = frozenset([
        OnboardingTask.INVITE_MEMBER,
        OnboardingTask.SECOND_PLATFORM,
        OnboardingTask.USER_CONTEXT,
        OnboardingTask.RELEASE_TRACKING,
        OnboardingTask.SOURCEMAPS,
        OnboardingTask.USER_REPORTS,
        OnboardingTask.ISSUE_TRACKER,
        OnboardingTask.ALERT_RULE,
    ])

    organization = FlexibleForeignKey("sentry.Organization")
    user = FlexibleForeignKey(settings.AUTH_USER_MODEL,
                              null=True)  # user that completed
    task = BoundedPositiveIntegerField(choices=TASK_CHOICES)
    status = BoundedPositiveIntegerField(choices=STATUS_CHOICES)
    date_completed = models.DateTimeField(default=timezone.now)
    project_id = BoundedBigIntegerField(blank=True, null=True)
    data = JSONField()  # INVITE_MEMBER { invited_member: user.id }

    objects = OrganizationOnboardingTaskManager()

    class Meta:
        app_label = "sentry"
        db_table = "sentry_organizationonboardingtask"
        unique_together = (("organization", "task"), )

    __repr__ = sane_repr("organization", "task")
Esempio n. 21
0
class AvatarBase(Model):
    """
    Base class for UserAvatar, OrganizationAvatar, TeamAvatar,
    and ProjectAvatar models. Associates those entities with their
    avatar preferences/files.
    """

    __include_in_export__ = False

    ALLOWED_SIZES = (20, 32, 36, 48, 52, 64, 80, 96, 120)

    FILE_TYPE = None

    file_id = BoundedBigIntegerField(unique=True, null=True)
    ident = models.CharField(max_length=32, unique=True, db_index=True)

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        if not self.ident:
            self.ident = uuid4().hex
        return super().save(*args, **kwargs)

    def get_file(self):
        from sentry.models import File

        if self.file_id is None:
            return None

        try:
            return File.objects.get(pk=self.file_id)
        except File.DoesNotExist:
            # Best effort replication of previous behaviour with foreign key
            # which was set with on_delete=models.SET_NULL
            self.update(file_id=None)
            return None

    def delete(self, *args, **kwargs):
        file = self.get_file()
        if file:
            file.delete()
        return super().delete(*args, **kwargs)

    def get_cache_key(self, size):
        raise NotImplementedError

    def clear_cached_photos(self):
        cache.delete_many([self.get_cache_key(x) for x in self.ALLOWED_SIZES])

    def get_cached_photo(self, size):
        file = self.get_file()
        if not file:
            return
        if size not in self.ALLOWED_SIZES:
            size = min(self.ALLOWED_SIZES, key=lambda x: abs(x - size))
        cache_key = self.get_cache_key(size)
        photo = cache.get(cache_key)
        if photo is None:
            photo_file = file.getfile()
            with Image.open(photo_file) as image:
                image = image.resize((size, size), Image.LANCZOS)
                image_file = BytesIO()
                image.save(image_file, "PNG")
                photo = image_file.getvalue()
                cache.set(cache_key, photo)
        return photo

    @classmethod
    def save_avatar(cls, relation, type, avatar=None, filename=None):
        from sentry.models import File

        if avatar:
            with atomic_transaction(using=router.db_for_write(File)):
                photo = File.objects.create(name=filename, type=cls.FILE_TYPE)
                # XXX: Avatar may come in as a string instance in python2
                # if it's not wrapped in BytesIO.
                if isinstance(avatar, str):
                    avatar = BytesIO(force_bytes(avatar))
                photo.putfile(avatar)
        else:
            photo = None

        with atomic_transaction(using=(
                router.db_for_write(cls),
                router.db_for_write(File),
        )):
            instance, created = cls.objects.get_or_create(**relation)
            file = instance.get_file()
            if file and photo:
                file.delete()

            if photo:
                instance.file_id = photo.id
                instance.ident = uuid4().hex

            instance.avatar_type = [
                i for i, n in cls.AVATAR_TYPES if n == type
            ][0]

            instance.save()

        if photo and not created:
            instance.clear_cached_photos()

        return instance
Esempio n. 22
0
class ReleaseFile(Model):
    r"""
    A ReleaseFile is an association between a Release and a File.

    The ident of the file should be sha1(name) or
    sha1(name '\x00\x00' dist.name) and must be unique per release.
    """
    __include_in_export__ = False

    organization_id = BoundedBigIntegerField()
    # DEPRECATED
    project_id = BoundedPositiveIntegerField(null=True)
    release_id = BoundedBigIntegerField()
    file = FlexibleForeignKey("sentry.File")
    ident = models.CharField(max_length=40)
    name = models.TextField()
    dist_id = BoundedBigIntegerField(null=True)

    #: For classic file uploads, this field is 1.
    #: For release archives, this field is 0.
    #: For artifact indexes, this field is the number of artifacts contained
    #: in the index.
    artifact_count = BoundedPositiveIntegerField(null=True, default=1)

    __repr__ = sane_repr("release", "ident")

    objects = models.Manager()  # The default manager.
    public_objects = PublicReleaseFileManager()

    class Meta:
        unique_together = (("release_id", "ident"), )
        index_together = (("release_id", "name"), )
        app_label = "sentry"
        db_table = "sentry_releasefile"

    def save(self, *args, **kwargs):
        from sentry.models import Distribution

        if not self.ident and self.name:
            dist = None
            if self.dist_id:
                dist = Distribution.objects.get(pk=self.dist_id).name
            self.ident = type(self).get_ident(self.name, dist)
        return super().save(*args, **kwargs)

    def update(self, *args, **kwargs):
        # If our name is changing, we must also change the ident
        if "name" in kwargs and "ident" not in kwargs:
            dist_name = None
            dist_id = kwargs.get("dist_id") or self.dist_id
            if dist_id:
                dist_name = Distribution.objects.filter(
                    pk=dist_id).values_list("name", flat=True)[0]
            kwargs["ident"] = self.ident = type(self).get_ident(
                kwargs["name"], dist_name)
        return super().update(*args, **kwargs)

    @classmethod
    def get_ident(cls, name, dist=None):
        if dist is not None:
            return sha1_text(name + "\x00\x00" + dist).hexdigest()
        return sha1_text(name).hexdigest()

    @classmethod
    def normalize(cls, url):
        """Transforms a full absolute url into 2 or 4 generalized options

        * the original url as input
        * (optional) original url without querystring
        * the full url, but stripped of scheme and netloc
        * (optional) full url without scheme and netloc or querystring
        """
        # Always ignore the fragment
        scheme, netloc, path, query, _ = urlsplit(url)

        uri_without_fragment = (scheme, netloc, path, query, "")
        uri_relative = ("", "", path, query, "")
        uri_without_query = (scheme, netloc, path, "", "")
        uri_relative_without_query = ("", "", path, "", "")

        urls = [urlunsplit(uri_without_fragment)]
        if query:
            urls.append(urlunsplit(uri_without_query))
        urls.append("~" + urlunsplit(uri_relative))
        if query:
            urls.append("~" + urlunsplit(uri_relative_without_query))
        return urls
Esempio n. 23
0
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,
    )

    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

    # 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):
        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

    def _get_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)

    def get_legacy_message(self):
        msg_interface = self.data.get('logentry', {
            'message': self.message,
        })
        return msg_interface.get('formatted', msg_interface['message'])

    def get_event_type(self):
        """
        Return the type of this event.

        See ``sentry.eventtypes``.
        """
        return self.data.get('type', 'default')

    def get_event_metadata(self):
        """
        Return the metadata of this event.

        See ``sentry.eventtypes``.
        """
        etype = self.data.get('type', 'default')
        if 'metadata' not in self.data:
            # TODO(dcramer): remove after Dec 1 2016
            data = dict(self.data or {})
            data['message'] = self.message
            data = CanonicalKeyView(data)
            return eventtypes.get(etype)(data).get_metadata()
        return self.data['metadata']

    @property
    def title(self):
        et = eventtypes.get(self.get_event_type())(self.data)
        return et.to_string(self.get_event_metadata())

    def error(self):
        warnings.warn('Event.error is deprecated, use Event.title',
                      DeprecationWarning)
        return self.title

    error.short_description = _('error')

    @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

    @property
    def version(self):
        return self.data.get('version', '5')

    @memoize
    def ip_address(self):
        user_data = self.data.get('user', self.data.get('user'))
        if user_data:
            value = user_data.get('ip_address')
            if value:
                return value

        http_data = self.data.get('request', self.data.get('http'))
        if http_data and 'env' in http_data:
            value = http_data['env'].get('REMOTE_ADDR')
            if value:
                return value

        return None

    def get_interfaces(self):
        return CanonicalKeyView(get_interfaces(self.data))

    @memoize
    def interfaces(self):
        return self.get_interfaces()

    def get_tags(self):
        try:
            return sorted((t, v) for t, v in self.data.get('tags') or ())
        except ValueError:
            # at one point Sentry allowed invalid tag sets such as (foo, bar)
            # vs ((tag, foo), (tag, bar))
            return []

    tags = property(get_tags)

    def get_tag(self, key):
        for t, v in (self.data.get('tags') or ()):
            if t == key:
                return v
        return None

    @property
    def dist(self):
        return self.get_tag('sentry:dist')

    def as_dict(self):
        # We use a OrderedDict to keep elements ordered for a potential JSON serializer
        data = OrderedDict()
        data['id'] = self.event_id
        data['project'] = self.project_id
        data['release'] = self.get_tag('sentry:release')
        data['dist'] = self.dist
        data['platform'] = self.platform
        data['culprit'] = self.group.culprit
        data['message'] = self.get_legacy_message()
        data['datetime'] = self.datetime
        data['time_spent'] = self.time_spent
        data['tags'] = self.get_tags()
        for k, v in sorted(six.iteritems(self.data)):
            if k == 'sdk':
                v = {
                    v_k: v_v
                    for v_k, v_v in six.iteritems(v) if v_k != 'client_ip'
                }
            data[k] = v
        return data

    @property
    def size(self):
        data_len = 0
        for value in six.itervalues(self.data):
            data_len += len(repr(value))
        return data_len

    # XXX(dcramer): compatibility with plugins
    def get_level_display(self):
        warnings.warn(
            'Event.get_level_display is deprecated. Use Event.tags instead.',
            DeprecationWarning)
        return self.group.get_level_display()

    @property
    def level(self):
        warnings.warn('Event.level is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.group.level

    @property
    def logger(self):
        warnings.warn('Event.logger is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.get_tag('logger')

    @property
    def site(self):
        warnings.warn('Event.site is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.get_tag('site')

    @property
    def server_name(self):
        warnings.warn(
            'Event.server_name is deprecated. Use Event.tags instead.')
        return self.get_tag('server_name')

    @property
    def culprit(self):
        warnings.warn(
            'Event.culprit is deprecated. Use Group.culprit instead.')
        return self.group.culprit

    @property
    def checksum(self):
        warnings.warn('Event.checksum is no longer used', DeprecationWarning)
        return ''

    @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
Esempio n. 24
0
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,
    )

    objects = BaseManager()

    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')

    # 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):
        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

    def _get_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)

    def error(self):
        message = strip(self.message)
        if not message:
            message = '<unlabeled message>'
        else:
            message = truncatechars(message.splitlines()[0], 100)
        return message

    error.short_description = _('error')

    def has_two_part_message(self):
        message = strip(self.message)
        return '\n' in message or len(message) > 100

    @property
    def message_short(self):
        message = strip(self.message)
        if not message:
            message = '<unlabeled message>'
        else:
            message = truncatechars(message.splitlines()[0], 100)
        return message

    @property
    def team(self):
        return self.project.team

    @property
    def organization(self):
        return self.project.organization

    @property
    def version(self):
        return self.data.get('version', '5')

    @memoize
    def ip_address(self):
        user_data = self.data.get('sentry.interfaces.User',
                                  self.data.get('user'))
        if user_data:
            value = user_data.get('ip_address')
            if value:
                return value

        http_data = self.data.get('sentry.interfaces.Http',
                                  self.data.get('http'))
        if http_data and 'env' in http_data:
            value = http_data['env'].get('REMOTE_ADDR')
            if value:
                return value

        return None

    def get_interfaces(self):
        result = []
        for key, data in self.data.iteritems():
            try:
                cls = get_interface(key)
            except ValueError:
                continue

            value = safe_execute(cls.to_python, data, _with_transaction=False)
            if not value:
                continue

            result.append((key, value))

        return OrderedDict((k, v) for k, v in sorted(
            result, key=lambda x: x[1].get_score(), reverse=True))

    @memoize
    def interfaces(self):
        return self.get_interfaces()

    def get_tags(self, with_internal=True):
        try:
            return sorted((t, v) for t, v in self.data.get('tags') or ()
                          if with_internal or not t.startswith('sentry:'))
        except ValueError:
            # at one point Sentry allowed invalid tag sets such as (foo, bar)
            # vs ((tag, foo), (tag, bar))
            return []

    tags = property(get_tags)

    def get_tag(self, key):
        for t, v in (self.data.get('tags') or ()):
            if t == key:
                return v
        return None

    def as_dict(self):
        # We use a OrderedDict to keep elements ordered for a potential JSON serializer
        data = OrderedDict()
        data['id'] = self.event_id
        data['project'] = self.project_id
        data['release'] = self.get_tag('sentry:release')
        data['platform'] = self.platform
        data['culprit'] = self.group.culprit
        data['message'] = self.message
        data['datetime'] = self.datetime
        data['time_spent'] = self.time_spent
        data['tags'] = self.get_tags()
        for k, v in sorted(self.data.iteritems()):
            data[k] = v
        return data

    @property
    def size(self):
        data_len = len(self.message)
        for value in self.data.itervalues():
            data_len += len(repr(value))
        return data_len

    # XXX(dcramer): compatibility with plugins
    def get_level_display(self):
        warnings.warn(
            'Event.get_level_display is deprecated. Use Event.tags instead.',
            DeprecationWarning)
        return self.group.get_level_display()

    @property
    def level(self):
        warnings.warn('Event.level is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.group.level

    @property
    def logger(self):
        warnings.warn('Event.logger is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.get_tag('logger')

    @property
    def site(self):
        warnings.warn('Event.site is deprecated. Use Event.tags instead.',
                      DeprecationWarning)
        return self.get_tag('site')

    @property
    def server_name(self):
        warnings.warn(
            'Event.server_name is deprecated. Use Event.tags instead.')
        return self.get_tag('server_name')

    @property
    def culprit(self):
        warnings.warn(
            'Event.culprit is deprecated. Use Group.culprit instead.')
        return self.group.culprit

    @property
    def checksum(self):
        warnings.warn('Event.checksum is no longer used', DeprecationWarning)
        return ''
Esempio n. 25
0
class GroupTagValue(Model):
    """
    Stores the total number of messages seen by a group matching
    the given filter.
    """
    __core__ = False

    project_id = BoundedBigIntegerField(db_index=True)
    group_id = BoundedBigIntegerField(db_index=True)
    times_seen = BoundedPositiveIntegerField(default=0)
    _key = FlexibleForeignKey('tagstore.TagKey', db_column='key_id')
    _value = FlexibleForeignKey('tagstore.TagValue', db_column='value_id')
    last_seen = models.DateTimeField(default=timezone.now,
                                     db_index=True,
                                     null=True)
    first_seen = models.DateTimeField(default=timezone.now,
                                      db_index=True,
                                      null=True)

    objects = BaseManager()

    class Meta:
        app_label = 'tagstore'
        unique_together = (('project_id', 'group_id', '_key', '_value'), )
        index_together = (('project_id', '_key', '_value', 'last_seen'), )

    __repr__ = sane_repr('project_id', 'group_id', '_key', '_value')

    @property
    def key(self):
        if hasattr(self, '_set_key'):
            return self._set_key

        return self._key.key

    @key.setter
    def key(self, key):
        self._set_key = key

    @property
    def value(self):
        if hasattr(self, '_set_value'):
            return self._set_value

        return self._value.value

    @value.setter
    def value(self, value):
        self._set_value = value

    def save(self, *args, **kwargs):
        if not self.first_seen:
            self.first_seen = self.last_seen
        super(GroupTagValue, self).save(*args, **kwargs)

    @staticmethod
    def get_select_related_for_merge():
        return (
            '_key',
            '_value',
        )

    def merge_counts(self, new_group):
        try:
            with transaction.atomic(using=router.db_for_write(GroupTagValue)):
                new_obj = GroupTagValue.objects.get(
                    group_id=new_group.id,
                    _key_id=self._key_id,
                    _value_id=self._value_id,
                )
                new_obj.update(
                    first_seen=min(new_obj.first_seen, self.first_seen),
                    last_seen=max(new_obj.last_seen, self.last_seen),
                    times_seen=new_obj.times_seen + self.times_seen,
                )
        except DataError:
            # it's possible to hit an out of range value for counters
            pass
Esempio n. 26
0
class GroupTagValue(Model):
    """
    Stores the total number of messages seen by a group matching
    the given filter.
    """

    __core__ = False

    project_id = BoundedBigIntegerField(db_index=True)
    group_id = BoundedBigIntegerField(db_index=True)
    times_seen = BoundedPositiveIntegerField(default=0)
    _key = FlexibleForeignKey("tagstore.TagKey", db_column="key_id")
    _value = FlexibleForeignKey("tagstore.TagValue", db_column="value_id")
    last_seen = models.DateTimeField(default=timezone.now,
                                     db_index=True,
                                     null=True)
    first_seen = models.DateTimeField(default=timezone.now,
                                      db_index=True,
                                      null=True)

    objects = TagStoreManager()

    class Meta:
        app_label = "tagstore"
        unique_together = (("project_id", "group_id", "_key", "_value"), )
        index_together = (("project_id", "_key", "_value", "last_seen"), )

    __repr__ = sane_repr("project_id", "group_id", "_key_id", "_value_id")

    def delete(self):
        using = router.db_for_read(GroupTagValue)
        cursor = connections[using].cursor()
        cursor.execute(
            """
            DELETE FROM tagstore_grouptagvalue
            WHERE project_id = %s
              AND id = %s
        """,
            [self.project_id, self.id],
        )

    @property
    def key(self):
        if hasattr(self, "_set_key"):
            return self._set_key

        if hasattr(self, "__key_cache"):
            return self._key.key

        # fallback
        from sentry.tagstore.v2.models import TagKey

        try:
            tk = (TagKey.objects.filter(project_id=self.project_id,
                                        id=self._key_id).values_list(
                                            "key", flat=True).get())
        except TagKey.DoesNotExist:
            # Data got inconsistent, I must delete myself.
            self.delete()
            return None

        # cache for future calls
        self.key = tk

        return tk

    @key.setter
    def key(self, key):
        self._set_key = key

    @property
    def value(self):
        if hasattr(self, "_set_value"):
            return self._set_value

        if hasattr(self, "__value_cache"):
            return self._value.value

        # fallback
        from sentry.tagstore.v2.models import TagValue

        try:
            tv = (TagValue.objects.filter(project_id=self.project_id,
                                          id=self._value_id).values_list(
                                              "value", flat=True).get())
        except TagValue.DoesNotExist:
            # Data got inconsistent, I must delete myself.
            self.delete()
            return ""

        # cache for future calls
        self.value = tv

        return tv

    @value.setter
    def value(self, value):
        self._set_value = value

    def save(self, *args, **kwargs):
        if not self.first_seen:
            self.first_seen = self.last_seen
        super(GroupTagValue, self).save(*args, **kwargs)

    def merge_counts(self, new_group):
        try:
            with transaction.atomic(using=router.db_for_write(GroupTagValue)):
                new_obj, _ = GroupTagValue.objects.get_or_create(
                    group_id=new_group.id,
                    project_id=new_group.project_id,
                    _key_id=self._key_id,
                    _value_id=self._value_id,
                )

                GroupTagValue.objects.filter(
                    id=new_obj.id, project_id=new_group.project_id).update(
                        first_seen=min(new_obj.first_seen, self.first_seen),
                        last_seen=max(new_obj.last_seen, self.last_seen),
                        times_seen=new_obj.times_seen + self.times_seen,
                    )
        except DataError:
            # it's possible to hit an out of range value for counters
            pass
Esempio n. 27
0
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=EventDict,
    )

    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
    # we grab the next 5 / prev 5 events by datetime, and sort locally to
    # get the next/prev events. Given that timestamps only have 1-second
    # granularity, this will be inaccurate if there are more than 5 events
    # in a given second.
    def next_event_id(self, environments=None):
        events = self.__class__.objects.filter(
            datetime__gte=self.datetime,
            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.sort(key=EVENT_ORDERING_KEY)
        return six.text_type(events[0].event_id) if events else None

    def prev_event_id(self, environments=None):
        events = self.__class__.objects.filter(
            datetime__lte=self.datetime,
            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.sort(key=EVENT_ORDERING_KEY, reverse=True)
        return six.text_type(events[0].event_id) if events else None
Esempio n. 28
0
class TagKey(Model):
    """
    Stores references to available filters keys.
    """
    __core__ = False

    project_id = BoundedBigIntegerField(db_index=True)
    environment_id = BoundedBigIntegerField()
    key = models.CharField(max_length=MAX_TAG_KEY_LENGTH)
    values_seen = BoundedPositiveIntegerField(default=0)
    status = BoundedPositiveIntegerField(choices=(
        (TagKeyStatus.VISIBLE, _('Visible')),
        (TagKeyStatus.PENDING_DELETION, _('Pending Deletion')),
        (TagKeyStatus.DELETION_IN_PROGRESS, _('Deletion in Progress')),
    ),
                                         default=TagKeyStatus.VISIBLE)

    objects = TagStoreManager()

    class Meta:
        app_label = 'tagstore'
        unique_together = (('project_id', 'environment_id', 'key'), )

    __repr__ = sane_repr('project_id', 'environment_id', 'key')

    def delete(self):
        using = router.db_for_read(TagKey)
        cursor = connections[using].cursor()
        cursor.execute(
            """
            DELETE FROM tagstore_tagkey
            WHERE project_id = %s
              AND id = %s
        """, [self.project_id, self.id])

    def get_label(self):
        from sentry import tagstore

        return tagstore.get_tag_key_label(self.key)

    def get_audit_log_data(self):
        return {
            'key': self.key,
        }

    @classmethod
    def get_cache_key(cls, project_id, environment_id, key):
        return 'tagkey:1:%s:%s:%s' % (project_id, environment_id,
                                      md5_text(key).hexdigest())

    @classmethod
    def get_or_create(cls, project_id, environment_id, key, **kwargs):
        cache_key = cls.get_cache_key(project_id, environment_id, key)

        rv = cache.get(cache_key)
        created = False
        if rv is None:
            rv, created = cls.objects.get_or_create(
                project_id=project_id,
                environment_id=environment_id,
                key=key,
                **kwargs)
            cache.set(cache_key, rv, 3600)

        return rv, created

    @classmethod
    def get_or_create_bulk(cls, project_id, environment_id, keys):
        # Attempt to create a bunch of models in one big batch with as few
        # queries and cache calls as possible.
        # In best case, this is all done in 1 cache get.
        # In ideal case, we'll do 3 queries total instead of N.
        # Absolute worst case, we still just do O(n) queries, but this should be rare.
        key_to_model = {key: None for key in keys}
        remaining_keys = set(keys)

        # First attempt to hit from cache, which in theory is the hot case
        cache_key_to_key = {
            cls.get_cache_key(project_id, environment_id, key): key
            for key in keys
        }
        cache_key_to_models = cache.get_many(cache_key_to_key.keys())
        for model in cache_key_to_models.values():
            key_to_model[model.key] = model
            remaining_keys.remove(model.key)

        if not remaining_keys:
            # 100% cache hit on all items, good work team
            return key_to_model

        # If we have some misses, we want to first check if
        # all of the misses actually exist in the database
        # already in one bulk query.
        to_cache = {}
        for model in cls.objects.filter(
                project_id=project_id,
                environment_id=environment_id,
                key__in=remaining_keys,
        ):
            key_to_model[model.key] = to_cache[cls.get_cache_key(
                project_id, environment_id, model.key)] = model
            remaining_keys.remove(model.key)

        # If we have found them all, cache all these misses
        # and return all the hits.
        if not remaining_keys:
            cache.set_many(to_cache, 3600)
            return key_to_model

        # At this point, we need to create all of our keys, since they
        # don't exist in cache or the database.

        # First attempt to create them all in one bulk query
        try:
            with transaction.atomic():
                cls.objects.bulk_create([
                    cls(
                        project_id=project_id,
                        environment_id=environment_id,
                        key=key,
                    ) for key in remaining_keys
                ])
        except IntegrityError:
            pass
        else:
            # If we succeed, the shitty part is we need one
            # more query to get back the actual rows with their ids.
            for model in cls.objects.filter(project_id=project_id,
                                            environment_id=environment_id,
                                            key__in=remaining_keys):
                key_to_model[model.key] = to_cache[cls.get_cache_key(
                    project_id, environment_id, model.key)] = model
                remaining_keys.remove(model.key)

            cache.set_many(to_cache, 3600)

            # Not clear if this could actually happen, but if it does,
            # guard ourselves against returning bad data.
            if not remaining_keys:
                return key_to_model

        # Fall back to just doing it manually
        # This case will only ever happen in a race condition.
        for key in remaining_keys:
            key_to_model[key] = cls.get_or_create(project_id, environment_id,
                                                  key)[0]

        return key_to_model
Esempio n. 29
0
class GroupRelease(Model):
    __include_in_export__ = False

    # TODO: Should be BoundedBigIntegerField
    project_id = BoundedPositiveIntegerField(db_index=True)
    group_id = BoundedBigIntegerField()
    # TODO: Should be BoundedBigIntegerField
    release_id = BoundedPositiveIntegerField(db_index=True)
    environment = models.CharField(max_length=64, default="")
    first_seen = models.DateTimeField(default=timezone.now)
    last_seen = models.DateTimeField(default=timezone.now, db_index=True)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_grouprelease"
        unique_together = (("group_id", "release_id", "environment"), )
        index_together = (
            ("group_id", "first_seen"),
            ("group_id", "last_seen"),
        )

    __repr__ = sane_repr("group_id", "release_id")

    @classmethod
    def get_cache_key(cls, group_id, release_id, environment):
        return "grouprelease:1:{}:{}".format(
            group_id,
            md5_text(f"{release_id}:{environment}").hexdigest())

    @classmethod
    def get_or_create(cls, group, release, environment, datetime, **kwargs):
        cache_key = cls.get_cache_key(group.id, release.id, environment.name)

        instance = cache.get(cache_key)
        if instance is None:
            try:
                with transaction.atomic():
                    instance, created = (
                        cls.objects.create(
                            release_id=release.id,
                            group_id=group.id,
                            environment=environment.name,
                            project_id=group.project_id,
                            first_seen=datetime,
                            last_seen=datetime,
                        ),
                        True,
                    )
            except IntegrityError:
                instance, created = (
                    cls.objects.get(release_id=release.id,
                                    group_id=group.id,
                                    environment=environment.name),
                    False,
                )
        else:
            created = False

        if not created and instance.last_seen < datetime - timedelta(
                seconds=60):
            buffer.incr(
                model=cls,
                columns={},
                filters={"id": instance.id},
                extra={"last_seen": datetime},
            )
            instance.last_seen = datetime

        cache.set(cache_key, instance, 3600)
        return instance
Esempio n. 30
0
class TagValue(Model):
    """
    Stores references to available filters.
    """
    __core__ = False

    project_id = BoundedBigIntegerField(db_index=True)
    _key = FlexibleForeignKey('tagstore.TagKey', db_column='key_id')
    value = models.CharField(max_length=MAX_TAG_VALUE_LENGTH)
    data = GzippedDictField(blank=True, null=True)
    times_seen = BoundedPositiveIntegerField(default=0)
    last_seen = models.DateTimeField(default=timezone.now,
                                     db_index=True,
                                     null=True)
    first_seen = models.DateTimeField(default=timezone.now,
                                      db_index=True,
                                      null=True)

    objects = BaseManager()

    class Meta:
        app_label = 'tagstore'
        unique_together = (('project_id', '_key', 'value'), )
        index_together = (('project_id', '_key', 'last_seen'), )

    __repr__ = sane_repr('project_id', '_key', 'value')

    @property
    def key(self):
        if hasattr(self, '_set_key'):
            return self._set_key

        if hasattr(self, '__key_cache'):
            return self._key.key

        # fallback
        from sentry.tagstore.v2.models import TagKey

        tk = TagKey.objects.filter(
            project_id=self.project_id,
            id=self._key_id,
        ).values_list('key', flat=True).get()

        # cache for future calls
        self.key = tk

        return tk

    @key.setter
    def key(self, key):
        self._set_key = key

    def get_label(self):
        from sentry import tagstore

        return tagstore.get_tag_value_label(self.key, self.value)

    @classmethod
    def get_cache_key(cls, project_id, _key_id, value):
        return 'tagvalue:1:%s:%s:%s' % (project_id, _key_id,
                                        md5_text(value).hexdigest())

    @classmethod
    def get_or_create(cls, project_id, _key_id, value, **kwargs):
        cache_key = cls.get_cache_key(project_id, _key_id, value)

        rv = cache.get(cache_key)
        created = False
        if rv is None:
            rv, created = cls.objects.get_or_create(project_id=project_id,
                                                    _key_id=_key_id,
                                                    value=value,
                                                    **kwargs)
            cache.set(cache_key, rv, 3600)

        return rv, created