Skip to content

feat(app-platform): Add error.created hook event #13462

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Jun 13, 2019
Merged
2 changes: 2 additions & 0 deletions src/sentry/conf/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,8 @@ def create_partitioned_queues(name):
# Enable interface functionality to synchronize groups between sentry and
# issues on external services.
'organizations:integrations-issue-sync': True,
# Enable interface functionality to recieve event hooks.
'organizations:integrations-event-hooks': False,
# Special feature flag primarily used on the sentry.io SAAS product for
# easily enabling features while in early development.
'organizations:internal-catchall': False,
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/models/sentryapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,22 @@
'issue.ignored',
'issue.assigned',
],
'error': [
'error.created',
],
}

# We present Webhook Subscriptions per-resource (Issue, Project, etc.), not
# per-event-type (issue.created, project.deleted, etc.). These are valid
# resources a Sentry App may subscribe to.
VALID_EVENT_RESOURCES = (
'issue',
'error',
)

REQUIRED_EVENT_PERMISSIONS = {
'issue': 'event:read',
'error': 'event:read',
'project': 'project:read',
'member': 'member:read',
'organization': 'org:read',
Expand Down
5 changes: 5 additions & 0 deletions src/sentry/options/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,8 @@
# Normalization after processors
register('store.normalize-after-processing', default=0.0) # unused
register('store.disable-trim-in-renormalization', default=0.0)

# Post Process Error Hook Sampling
register('post-process.use-error-hook-sampling', default=False)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JTCunning / @tkaemming couldn't think of better names so open to others if this could be improved upon

# From 0.0 to 1.0: Randomly enqueue process_resource_change task
register('post-process.error-hook-sample-rate', default=0.0)
54 changes: 54 additions & 0 deletions src/sentry/tasks/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,52 @@ def _get_service_hooks(project_id):
return result


def _should_send_error_created_hooks(project):
from sentry.models import ServiceHook, Organization
from sentry import options
import random

use_sampling = options.get('post-process.use-error-hook-sampling')

# XXX(Meredith): Sampling is used to test the process_resource_change task.
# We have an option to explicity say we want to use sampling, and the other
# to determine what that rate should be.
# Going forward the sampling will be removed and the task will only be
# gated using the integrations-event-hooks (i.e. gated by plan)
#
# We also don't want to cache the result in case we need to manually lower the
# sample rate immediately, or turn it down completely.
if use_sampling:
if random.random() >= options.get('post-process.error-hook-sample-rate'):
return False

org = Organization.objects.get_from_cache(id=project.organization_id)
result = ServiceHook.objects.filter(
organization_id=org.id,
).extra(where=["events @> '{error.created}'"]).exists()

return result

cache_key = u'servicehooks-error-created:1:{}'.format(project.id)
result = cache.get(cache_key)

if result is None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you plan to use the sampling rate to keep the load under control (and possibly constant), then you may want to apply the sampling rate on the cached value, instead of filling the cache with a sampled value.
Otherwise you will have a spiky load with 100% of errors for a given project being sent to the webhook for 60 seconds periods when the cache says True no matter what the sampling rate is.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you plan to use the sampling rate to keep the load under control (and possibly constant)

Yes, this is for us to manually test the load and slowing increase the %, so yeah makes sense to not cache this value


org = Organization.objects.get_from_cache(id=project.organization_id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are using sampling you are not using this. You may avoid fetching the organization in that case. Unless you also want to check the feature when the sampling is on, then the check is missing below.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may avoid fetching the organization in that case

will move that into the else

Unless you also want to check the feature when the sampling is on, then the check is missing below.

I don't think we would need both

if not features.has('organizations:integrations-event-hooks', organization=org):
cache.set(cache_key, 0, 60)
return False

result = ServiceHook.objects.filter(
organization_id=org.id,
).extra(where=["events @> '{error.created}'"]).exists()

cache_value = 1 if result else 0
cache.set(cache_key, cache_value, 60)

return result


def _capture_stats(event, is_new):
# TODO(dcramer): limit platforms to... something?
group = event.group
Expand Down Expand Up @@ -161,6 +207,14 @@ def post_process_group(event, is_new, is_regression, is_sample, is_new_group_env
event=event,
)

if event.get_event_type() == 'error' and _should_send_error_created_hooks(event.project):
process_resource_change_bound.delay(
action='created',
sender='Error',
instance_id=event.event_id,
project_id=event.project_id,
group_id=event.group_id,
)
if is_new:
process_resource_change_bound.delay(
action='created',
Expand Down
108 changes: 80 additions & 28 deletions src/sentry/tasks/sentry_apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from django.core.urlresolvers import reverse
from requests.exceptions import RequestException

from sentry import options
from sentry import features
from sentry.http import safe_urlopen
from sentry.tasks.base import instrumented_task, retry
from sentry.utils.http import absolute_uri
from sentry.api.serializers import serialize, AppPlatformEvent
from sentry.models import (
SentryAppInstallation, Group, Project, Organization, User, ServiceHook, ServiceHookProject, SentryApp,
SentryAppInstallation, Event, EventCommon, Group, Project, Organization, User, ServiceHook, ServiceHookProject, SentryApp, SnubaEvent,
)
from sentry.models.sentryapp import VALID_EVENTS

Expand All @@ -30,11 +32,41 @@
'Group': 'issue',
}

USE_SNUBA = options.get('snuba.events-queries.enabled')

TYPES = {
'Group': Group,
'Error': SnubaEvent if USE_SNUBA else Event,
}


def _webhook_event_data(event, group_id, project_id):
project = Project.objects.get_from_cache(id=project_id)
organization = Organization.objects.get_from_cache(id=project.organization_id)

event_context = event.as_dict()
event_context['url'] = absolute_uri(reverse('sentry-api-0-project-event-details', args=[
project.organization.slug,
project.slug,
event.event_id,
]))

event_context['web_url'] = absolute_uri(reverse('sentry-organization-event-detail', args=[
organization.slug,
group_id,
event.event_id,
]))

# The URL has a regex OR in it ("|") which means `reverse` cannot generate
# a valid URL (it can't know which option to pick). We have to manually
# create this URL for, that reason.
event_context['issue_url'] = absolute_uri(
'/api/0/issues/{}/'.format(group_id),
)

return event_context


@instrumented_task(name='sentry.tasks.sentry_apps.send_alert_event', **TASK_OPTIONS)
@retry(on=(RequestException, ))
def send_alert_event(event, rule, sentry_app_id):
Expand Down Expand Up @@ -64,25 +96,7 @@ def send_alert_event(event, rule, sentry_app_id):
logger.info('event_alert_webhook.missing_installation', extra=extra)
return

event_context = event.as_dict()
event_context['url'] = absolute_uri(reverse('sentry-api-0-project-event-details', args=[
project.organization.slug,
project.slug,
event.event_id,
]))

event_context['web_url'] = absolute_uri(reverse('sentry-organization-event-detail', args=[
organization.slug,
group.id,
event.event_id,
]))

# The URL has a regex OR in it ("|") which means `reverse` cannot generate
# a valid URL (it can't know which option to pick). We have to manually
# create this URL for, that reason.
event_context['issue_url'] = absolute_uri(
'/api/0/issues/{}/'.format(group.id),
)
event_context = _webhook_event_data(event, group.id, project.id)

data = {
'event': event_context,
Expand All @@ -107,10 +121,31 @@ def send_alert_event(event, rule, sentry_app_id):
def _process_resource_change(action, sender, instance_id, retryer=None, *args, **kwargs):
# The class is serialized as a string when enqueueing the class.
model = TYPES[sender]

# Some resources are named differently than their model. eg. Group vs
# Issue. Looks up the human name for the model. Defaults to the model name.
name = RESOURCE_RENAMES.get(model.__name__, model.__name__.lower())
# The Event model has different hooks for the differenct types. The sender
# determines which type eg. Error and therefore the 'name' eg. error
if issubclass(model, EventCommon):
if not kwargs.get('project_id'):
extra = {
'sender': sender,
'action': action,
'event_id': instance_id,
}
logger.info('process_resource_change.event_missing_project_id', extra=extra)
return
if not kwargs.get('group_id'):
extra = {
'sender': sender,
'action': action,
'event_id': instance_id,
}
logger.info('process_resource_change.event_missing_group_id', extra=extra)
return

name = sender.lower()
else:
# Some resources are named differently than their model. eg. Group vs Issue.
# Looks up the human name for the model. Defaults to the model name.
name = RESOURCE_RENAMES.get(model.__name__, model.__name__.lower())

# By default, use Celery's `current` but allow a value to be passed for the
# bound Task.
Expand All @@ -119,7 +154,15 @@ def _process_resource_change(action, sender, instance_id, retryer=None, *args, *
# We may run into a race condition where this task executes before the
# transaction that creates the Group has committed.
try:
instance = model.objects.get(id=instance_id)
if issubclass(model, EventCommon):
# 'from_event_id' is supported for both Event and SnubaEvent
# instance_id is the event.event_id NOT the event.id
instance = model.objects.from_event_id(
instance_id,
kwargs.get('project_id'),
)
else:
instance = model.objects.get(id=instance_id)
except model.DoesNotExist as e:
# Explicitly requeue the task, so we don't report this to Sentry until
# we hit the max number of retries.
Expand All @@ -132,7 +175,7 @@ def _process_resource_change(action, sender, instance_id, retryer=None, *args, *

org = None

if isinstance(instance, Group):
if isinstance(instance, Group) or issubclass(model, EventCommon):
org = Organization.objects.get_from_cache(
id=Project.objects.get_from_cache(
id=instance.project_id
Expand All @@ -146,8 +189,17 @@ def _process_resource_change(action, sender, instance_id, retryer=None, *args, *

for installation in installations:
data = {}
data[name] = serialize(instance)
send_webhooks(installation, event, data=data)
if issubclass(model, EventCommon):
group_id = kwargs.get('group_id')
project_id = kwargs.get('project_id')
data[name] = _webhook_event_data(instance, group_id, project_id)
# XXX(Meredith): this flag is in place for testing the load this task creates
# and during testing we don't need to send the webhook.
if features.has('organizations:integrations-event-hooks', organization=org):
send_webhooks(installation, event, data=data)
else:
data[name] = serialize(instance)
send_webhooks(installation, event, data=data)


@instrumented_task('sentry.tasks.process_resource_change', **TASK_OPTIONS)
Expand Down
Loading