Skip to content
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

chore(sentry apps): New error design for external request flow #83678

Merged
merged 20 commits into from
Jan 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
from django.utils.functional import empty
from jsonschema import ValidationError
from rest_framework import serializers
from rest_framework.request import Request
from rest_framework.response import Response
from sentry_sdk import capture_exception

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.api.serializers import serialize
from sentry.coreapi import APIError, APIUnauthorized
from sentry.models.group import Group
from sentry.models.project import Project
from sentry.sentry_apps.api.bases.sentryapps import SentryAppInstallationBaseEndpoint
from sentry.sentry_apps.api.serializers.platform_external_issue import (
PlatformExternalIssueSerializer,
)
from sentry.sentry_apps.external_issues.issue_link_creator import IssueLinkCreator
from sentry.sentry_apps.utils.errors import SentryAppError
from sentry.users.models.user import User
from sentry.users.services.user.serial import serialize_rpc_user

Expand All @@ -34,6 +33,12 @@ def _extract_lazy_object(lo):
return lo._wrapped


class SentryAppInstallationExternalIssueActionsSerializer(serializers.Serializer):
groupId = serializers.CharField(required=True, allow_null=False)
action = serializers.CharField(required=True, allow_null=False)
uri = serializers.CharField(required=True, allow_null=False)


@region_silo_endpoint
class SentryAppInstallationExternalIssueActionsEndpoint(SentryAppInstallationBaseEndpoint):
owner = ApiOwner.INTEGRATIONS
Expand All @@ -44,8 +49,12 @@ class SentryAppInstallationExternalIssueActionsEndpoint(SentryAppInstallationBas
def post(self, request: Request, installation) -> Response:
data = request.data.copy()

if not {"groupId", "action", "uri"}.issubset(data.keys()):
return Response(status=400)
external_issue_action_serializer = SentryAppInstallationExternalIssueActionsSerializer(
data=data
)

if not external_issue_action_serializer.is_valid():
return Response(external_issue_action_serializer.errors, status=400)

group_id = data.get("groupId")
del data["groupId"]
Expand All @@ -56,34 +65,26 @@ def post(self, request: Request, installation) -> Response:
project_id__in=Project.objects.filter(organization_id=installation.organization_id),
)
except Group.DoesNotExist:
return Response(status=404)
raise SentryAppError(
message="Could not find the corresponding issue for the given groupId",
status_code=404,
)

action = data.pop("action")
uri = data.pop("uri")

try:
user = _extract_lazy_object(request.user)
if isinstance(user, User):
user = serialize_rpc_user(user)

external_issue = IssueLinkCreator(
install=installation,
group=group,
action=action,
fields=data,
uri=uri,
user=user,
).run()
except (APIError, ValidationError, APIUnauthorized) as e:
return Response({"error": str(e)}, status=400)
except Exception as e:
error_id = capture_exception(e)
return Response(
{
"error": f"Something went wrong while trying to link issue. Sentry error ID: {error_id}"
},
status=500,
)
Christinarlong marked this conversation as resolved.
Show resolved Hide resolved
user = _extract_lazy_object(request.user)
if isinstance(user, User):
user = serialize_rpc_user(user)

external_issue = IssueLinkCreator(
install=installation,
group=group,
action=action,
fields=data,
uri=uri,
user=user,
).run()

return Response(
serialize(objects=external_issue, serializer=PlatformExternalIssueSerializer())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
SentryAppInstallationExternalIssueBaseEndpoint as ExternalIssueBaseEndpoint,
)
from sentry.sentry_apps.models.platformexternalissue import PlatformExternalIssue
from sentry.sentry_apps.utils.errors import SentryAppError


@region_silo_endpoint
Expand All @@ -24,7 +25,10 @@ def delete(self, request: Request, installation, external_issue_id) -> Response:
service_type=installation.sentry_app.slug,
)
except PlatformExternalIssue.DoesNotExist:
return Response(status=404)
raise SentryAppError(
message="Could not find the corresponding external issue from given external_issue_id",
status_code=404,
)

deletions.exec_sync(platform_external_issue)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PlatformExternalIssueSerializer as ResponsePlatformExternalIssueSerializer,
)
from sentry.sentry_apps.external_issues.external_issue_creator import ExternalIssueCreator
from sentry.sentry_apps.utils.errors import SentryAppError


class PlatformExternalIssueSerializer(serializers.Serializer):
Expand All @@ -40,7 +41,10 @@ def post(self, request: Request, installation) -> Response:
project_id__in=Project.objects.filter(organization_id=installation.organization_id),
)
except Group.DoesNotExist:
return Response(status=404)
raise SentryAppError(
message="Could not find the corresponding issue for the given issueId",
status_code=404,
)

serializer = PlatformExternalIssueSerializer(data=request.data)
if serializer.is_valid():
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import logging

from jsonschema import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import region_silo_endpoint
from sentry.coreapi import APIError
from sentry.models.project import Project
from sentry.sentry_apps.api.bases.sentryapps import SentryAppInstallationBaseEndpoint
from sentry.sentry_apps.external_requests.select_requester import SelectRequester
Expand Down Expand Up @@ -40,18 +38,6 @@ def get(self, request: Request, installation) -> Response:
if project:
kwargs.update({"project_slug": project.slug})

try:
choices = SelectRequester(**kwargs).run()
except ValidationError as e:
return Response(
{"error": e.message},
status=400,
)
except APIError:
message = f'Error retrieving select field options from {request.GET.get("uri")}'
return Response(
{"error": message},
status=400,
)
Christinarlong marked this conversation as resolved.
Show resolved Hide resolved
choices = SelectRequester(**kwargs).run()

return Response(choices)
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,14 @@ def run(self) -> PlatformExternalIssue:
return self.external_issue[0]
except Exception as e:
logger.info(
"create-failed",
"platform-external-issue.create-failed",
exc_info=e,
extra={
"error": str(e),
"installtion_id": self.install.uuid,
"installation_id": self.install.uuid,
"group_id": self.group.id,
"sentry_app_slug": self.install.sentry_app.slug,
},
)
raise SentryAppSentryError(
message="Failed to create external issue obj",
webhook_context={"error": str(e)},
) from e
49 changes: 33 additions & 16 deletions src/sentry/sentry_apps/external_requests/issue_link_requester.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
from uuid import uuid4

from django.utils.functional import cached_property
from jsonschema import ValidationError
from requests import RequestException

from sentry.coreapi import APIError
from sentry.http import safe_urlread
from sentry.models.group import Group
from sentry.sentry_apps.external_requests.utils import send_and_save_sentry_app_request, validate
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
from sentry.sentry_apps.utils.errors import SentryAppIntegratorError
from sentry.users.models.user import User
from sentry.users.services.user import RpcUser
from sentry.utils import json
Expand Down Expand Up @@ -74,26 +74,43 @@ def run(self) -> dict[str, Any]:
body = safe_urlread(request)
response = json.loads(body)
except (json.JSONDecodeError, TypeError):
raise ValidationError(f"Unable to parse response from {self.sentry_app.slug}")
except Exception as e:
logger.info(
"issue-link-requester.error",
extra={
"sentry_app": self.sentry_app.slug,
"install": self.install.uuid,
"project": self.group.project.slug,
"group": self.group.id,
raise SentryAppIntegratorError(
message=f"Unable to parse response from {self.sentry_app.slug}",
webhook_context={
"error_type": "issue-link-requester.invalid-json",
"uri": self.uri,
"error_message": str(e),
"response": body,
"installation_uuid": self.install.uuid,
},
status_code=500,
)
raise APIError(
f"Issue occured while trying to contact {self.sentry_app.slug} to link issue"
except RequestException as e:
error_type = "issue-link-requester.error"
extras = {
"sentry_app": self.sentry_app.slug,
"installation_uuid": self.install.uuid,
"project": self.group.project.slug,
"group": self.group.id,
"uri": self.uri,
"error_message": str(e),
}
logger.info(error_type, extra=extras)
raise SentryAppIntegratorError(
message=f"Issue occured while trying to contact {self.sentry_app.slug} to link issue",
webhook_context={"error_type": error_type, **extras},
status_code=500,
)

if not self._validate_response(response):
raise ValidationError(
f"Invalid response format from sentry app {self.sentry_app} when linking issue"
raise SentryAppIntegratorError(
message=f"Invalid response format from sentry app {self.sentry_app} when linking issue",
webhook_context={
"error_type": "issue-link-requester.invalid-response",
"response": response,
"installation_uuid": self.install.uuid,
"uri": self.uri,
},
status_code=500,
)

return response
Expand Down
49 changes: 26 additions & 23 deletions src/sentry/sentry_apps/external_requests/select_requester.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@
from uuid import uuid4

from django.utils.functional import cached_property
from jsonschema import ValidationError

from sentry.coreapi import APIError
from sentry.http import safe_urlread
from sentry.sentry_apps.external_requests.utils import send_and_save_sentry_app_request, validate
from sentry.sentry_apps.services.app import RpcSentryAppInstallation
from sentry.sentry_apps.services.app.model import RpcSentryApp
from sentry.sentry_apps.utils.errors import SentryAppIntegratorError
from sentry.utils import json

logger = logging.getLogger("sentry.sentry_apps.external_requests")
Expand Down Expand Up @@ -61,7 +60,6 @@ def run(self) -> SelectRequesterResult:
"sentry_app_slug": self.sentry_app.slug,
"install_uuid": self.install.uuid,
"project_slug": self.project_slug,
"error_message": str(e),
}

if not url:
Expand All @@ -77,25 +75,30 @@ def run(self) -> SelectRequesterResult:
extra.update({"url": url})
message = "select-requester.request-failed"

logger.info(message, extra=extra)
raise APIError(
f"Something went wrong while getting SelectFields from {self.sentry_app.slug}"
logger.info(message, exc_info=e, extra=extra)
raise SentryAppIntegratorError(
message=f"Something went wrong while getting options for Select FormField from {self.sentry_app.slug}",
webhook_context={"error_type": message, **extra},
status_code=500,
) from e

if not self._validate_response(response):
logger.info(
"select-requester.invalid-response",
extra={
"response": response,
"sentry_app_slug": self.sentry_app.slug,
"install_uuid": self.install.uuid,
"project_slug": self.project_slug,
"url": url,
extras = {
"response": response,
"sentry_app_slug": self.sentry_app.slug,
"install_uuid": self.install.uuid,
"project_slug": self.project_slug,
"url": url,
}
logger.info("select-requester.invalid-response", extra=extras)

raise SentryAppIntegratorError(
message=f"Invalid response format for Select FormField in {self.sentry_app.slug} from uri: {self.uri}",
webhook_context={
"error_type": "select-requester.invalid-integrator-response",
**extras,
},
)
raise ValidationError(
f"Invalid response format for SelectField in {self.sentry_app.slug} from uri: {self.uri}"
)
return self._format_response(response)

def _build_url(self) -> str:
Expand Down Expand Up @@ -130,14 +133,14 @@ def _format_response(self, resp: Sequence[dict[str, Any]]) -> SelectRequesterRes

for option in resp:
if not ("value" in option and "label" in option):
logger.info(
"select-requester.invalid-response",
extra={
"resposnse": resp,
"error_msg": "Missing `value` or `label` in option data for SelectField",
raise SentryAppIntegratorError(
message="Missing `value` or `label` in option data for Select FormField",
webhook_context={
"error_type": "select-requester.missing-fields",
"response": resp,
},
status_code=500,
)
raise ValidationError("Missing `value` or `label` in option data for SelectField")

choices.append([option["value"], option["label"]])

Expand Down
13 changes: 12 additions & 1 deletion src/sentry/sentry_apps/models/sentry_app_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
from sentry.hybridcloud.outbox.category import OutboxCategory
from sentry.projects.services.project import RpcProject
from sentry.sentry_apps.services.app.model import RpcSentryAppComponent, RpcSentryAppInstallation
from sentry.sentry_apps.utils.errors import (
SentryAppError,
SentryAppIntegratorError,
SentryAppSentryError,
)
from sentry.types.region import find_regions_for_orgs

if TYPE_CHECKING:
Expand Down Expand Up @@ -240,6 +245,12 @@ def prepare_ui_component(
component=component, install=installation, project_slug=project_slug, values=values
).run()
return component
except (APIError, ValidationError):
except (
APIError,
ValidationError,
SentryAppIntegratorError,
SentryAppError,
SentryAppSentryError,
):
# TODO(nisanthan): For now, skip showing the UI Component if the API requests fail
return None
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ def test_external_issue_doesnt_get_created(self):
)

response = self.client.post(self.url, data=data, format="json")
assert response.status_code == 400
assert response.status_code == 500
assert (
response.content
== b'{"error":"Issue occured while trying to contact testin to link issue"}'
== b'{"detail":"Issue occured while trying to contact testin to link issue"}'
)
assert not PlatformExternalIssue.objects.all()
Loading
Loading