diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index f1f79cfb102eb..f3b439414e0a4 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -3,6 +3,7 @@ import logging import re from collections.abc import Mapping, Sequence +from enum import StrEnum from typing import Any from urllib.parse import parse_qsl @@ -28,6 +29,10 @@ from sentry.integrations.models.organization_integration import OrganizationIntegration from sentry.integrations.services.repository import RpcRepository, repository_service from sentry.integrations.source_code_management.commit_context import CommitContextIntegration +from sentry.integrations.source_code_management.metrics import ( + SCMPipelineViewEvent, + SCMPipelineViewType, +) from sentry.integrations.source_code_management.repository import RepositoryIntegration from sentry.integrations.tasks.migrate_repo import migrate_repo from sentry.integrations.utils.code_mapping import RepoTree @@ -399,57 +404,84 @@ def setup(self) -> None: ) -class OAuthLoginView(PipelineView): - def dispatch(self, request: Request, pipeline) -> HttpResponseBase: - self.determine_active_organization(request) +class GitHubInstallationError(StrEnum): + INVALID_STATE = "Invalid state" + MISSING_TOKEN = "Missing access token" + MISSING_LOGIN = "Missing login info" + PENDING_DELETION = "GitHub installation pending deletion." + INSTALLATION_EXISTS = "Github installed on another Sentry organization." + USER_MISMATCH = "Authenticated user is not the same as who installed the app." - ghip = GitHubIdentityProvider() - github_client_id = ghip.get_oauth_client_id() - github_client_secret = ghip.get_oauth_client_secret() - installation_id = request.GET.get("installation_id") - if installation_id: - pipeline.bind_state("installation_id", installation_id) - - if not request.GET.get("state"): - state = pipeline.signature - - redirect_uri = absolute_uri( - reverse("sentry-extension-setup", kwargs={"provider_id": "github"}) - ) - return self.redirect( - f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}" +class OAuthLoginView(PipelineView): + def dispatch(self, request: Request, pipeline) -> HttpResponseBase: + with SCMPipelineViewEvent( + SCMPipelineViewType.OAUTH_LOGIN, pipeline.provider.key + ).capture() as lifecycle: + self.determine_active_organization(request) + lifecycle.add_extra( + "organization_id", + self.active_organization.organization.id if self.active_organization else None, ) - # At this point, we are past the GitHub "authorize" step - if request.GET.get("state") != pipeline.signature: - return error(request, self.active_organization, error_short="Invalid state") - - # similar to OAuth2CallbackView.get_token_params - data = { - "code": request.GET.get("code"), - "client_id": github_client_id, - "client_secret": github_client_secret, - } - - # similar to OAuth2CallbackView.exchange_token - req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data) - - try: - body = safe_urlread(req).decode("utf-8") - payload = dict(parse_qsl(body)) - except Exception: - payload = {} - - if "access_token" not in payload: - return error(request, self.active_organization, error_short="Missing access token") - - authenticated_user_info = get_user_info(payload["access_token"]) - if "login" not in authenticated_user_info: - return error(request, self.active_organization, error_short="Missing login info") + ghip = GitHubIdentityProvider() + github_client_id = ghip.get_oauth_client_id() + github_client_secret = ghip.get_oauth_client_secret() + + installation_id = request.GET.get("installation_id") + if installation_id: + pipeline.bind_state("installation_id", installation_id) + + if not request.GET.get("state"): + state = pipeline.signature + + redirect_uri = absolute_uri( + reverse("sentry-extension-setup", kwargs={"provider_id": "github"}) + ) + return self.redirect( + f"{ghip.get_oauth_authorize_url()}?client_id={github_client_id}&state={state}&redirect_uri={redirect_uri}" + ) + + # At this point, we are past the GitHub "authorize" step + if request.GET.get("state") != pipeline.signature: + lifecycle.record_failure({"failure_reason": GitHubInstallationError.INVALID_STATE}) + return error( + request, + self.active_organization, + error_short=GitHubInstallationError.INVALID_STATE, + ) + + # similar to OAuth2CallbackView.get_token_params + data = { + "code": request.GET.get("code"), + "client_id": github_client_id, + "client_secret": github_client_secret, + } - pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"]) - return pipeline.next_step() + # similar to OAuth2CallbackView.exchange_token + req = safe_urlopen(url=ghip.get_oauth_access_token_url(), data=data) + + try: + body = safe_urlread(req).decode("utf-8") + payload = dict(parse_qsl(body)) + except Exception: + payload = {} + + if "access_token" not in payload: + lifecycle.record_failure({"failure_reason": GitHubInstallationError.MISSING_TOKEN}) + return error( + request, + self.active_organization, + error_short=GitHubInstallationError.MISSING_TOKEN, + ) + + authenticated_user_info = get_user_info(payload["access_token"]) + if "login" not in authenticated_user_info: + lifecycle.record_failure({"failure_reason", GitHubInstallationError.MISSING_LOGIN}) + return error(request, self.active_organization, error_short="Missing login info") + + pipeline.bind_state("github_authenticated_user", authenticated_user_info["login"]) + return pipeline.next_step() class GitHubInstallation(PipelineView): @@ -458,67 +490,82 @@ def get_app_url(self) -> str: return f"https://github.com/apps/{slugify(name)}" def dispatch(self, request: Request, pipeline: Pipeline) -> HttpResponseBase: - installation_id = request.GET.get( - "installation_id", pipeline.fetch_state("installation_id") - ) - if installation_id is None: - return self.redirect(self.get_app_url()) - - pipeline.bind_state("installation_id", installation_id) - self.determine_active_organization(request) - - integration_pending_deletion_exists = False - if self.active_organization: - # We want to wait until the scheduled deletions finish or else the - # post install to migrate repos do not work. - integration_pending_deletion_exists = OrganizationIntegration.objects.filter( - integration__provider=GitHubIntegrationProvider.key, - organization_id=self.active_organization.organization.id, - status=ObjectStatus.PENDING_DELETION, - ).exists() - - if integration_pending_deletion_exists: - return error( - request, - self.active_organization, - error_short="GitHub installation pending deletion.", - error_long=ERR_INTEGRATION_PENDING_DELETION, + with SCMPipelineViewEvent( + SCMPipelineViewType.GITHUB_INSTALLATION, pipeline.provider.key + ).capture() as lifecycle: + installation_id = request.GET.get( + "installation_id", pipeline.fetch_state("installation_id") ) + if installation_id is None: + return self.redirect(self.get_app_url()) - try: - # We want to limit GitHub integrations to 1 organization - installations_exist = OrganizationIntegration.objects.filter( - integration=Integration.objects.get(external_id=installation_id) - ).exists() - - except Integration.DoesNotExist: - return pipeline.next_step() - - if installations_exist: - return error( - request, - self.active_organization, - error_short="Github installed on another Sentry organization.", - error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG, + pipeline.bind_state("installation_id", installation_id) + self.determine_active_organization(request) + lifecycle.add_extra( + "organization_id", + self.active_organization.organization.id if self.active_organization else None, ) - # OrganizationIntegration does not exist, but Integration does exist. - try: - integration = Integration.objects.get( - external_id=installation_id, status=ObjectStatus.ACTIVE - ) - except Integration.DoesNotExist: - return error(request, self.active_organization) - - # Check that the authenticated GitHub user is the same as who installed the app. - if ( - pipeline.fetch_state("github_authenticated_user") - != integration.metadata["sender"]["login"] - ): - return error( - request, - self.active_organization, - error_short="Authenticated user is not the same as who installed the app", - ) + integration_pending_deletion_exists = False + if self.active_organization: + # We want to wait until the scheduled deletions finish or else the + # post install to migrate repos do not work. + integration_pending_deletion_exists = OrganizationIntegration.objects.filter( + integration__provider=GitHubIntegrationProvider.key, + organization_id=self.active_organization.organization.id, + status=ObjectStatus.PENDING_DELETION, + ).exists() + + if integration_pending_deletion_exists: + lifecycle.record_failure( + {"failure_reason": GitHubInstallationError.PENDING_DELETION} + ) + return error( + request, + self.active_organization, + error_short=GitHubInstallationError.PENDING_DELETION, + error_long=ERR_INTEGRATION_PENDING_DELETION, + ) + + try: + # We want to limit GitHub integrations to 1 organization + installations_exist = OrganizationIntegration.objects.filter( + integration=Integration.objects.get(external_id=installation_id) + ).exists() + + except Integration.DoesNotExist: + return pipeline.next_step() + + if installations_exist: + lifecycle.record_failure( + {"failure_reason", GitHubInstallationError.INSTALLATION_EXISTS} + ) + return error( + request, + self.active_organization, + error_short=GitHubInstallationError.INSTALLATION_EXISTS, + error_long=ERR_INTEGRATION_EXISTS_ON_ANOTHER_ORG, + ) + + # OrganizationIntegration does not exist, but Integration does exist. + try: + integration = Integration.objects.get( + external_id=installation_id, status=ObjectStatus.ACTIVE + ) + except Integration.DoesNotExist: + lifecycle.record_failure({"failure_reason": "Integration does not exist"}) + return error(request, self.active_organization) + + # Check that the authenticated GitHub user is the same as who installed the app. + if ( + pipeline.fetch_state("github_authenticated_user") + != integration.metadata["sender"]["login"] + ): + lifecycle.record_failure({"failure_reason": GitHubInstallationError.USER_MISMATCH}) + return error( + request, + self.active_organization, + error_short=GitHubInstallationError.USER_MISMATCH, + ) - return pipeline.next_step() + return pipeline.next_step() diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py new file mode 100644 index 0000000000000..ef0296b8b63f3 --- /dev/null +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from enum import Enum + +from sentry.integrations.utils.metrics import EventLifecycleMetric, EventLifecycleOutcome + + +class SCMPipelineViewType(Enum): + """A specific step in an SCM integration's pipeline that is not a static page.""" + + # IdentityProviderPipeline + IDENTITY_PROVIDER = "IDENTITY_PROVIDER" + + # GitHub + OAUTH_LOGIN = "OAUTH_LOGIN" + GITHUB_INSTALLATION = "GITHUB_INSTALLATION" + + # Bitbucket + VERIFY_INSTALLATION = "VERIFY_INSTALLATION" + + # Bitbucket Server + # OAUTH_LOGIN = "OAUTH_LOGIN" + OAUTH_CALLBACK = "OAUTH_CALLBACK" + + # Azure DevOps + ACCOUNT_CONFIG = "ACCOUNT_CONFIG" + + def __str__(self) -> str: + return self.value.lower() + + +@dataclass +class SCMPipelineViewEvent(EventLifecycleMetric): + """An instance to be recorded of a user going through an integration pipeline view (step).""" + + interaction_type: SCMPipelineViewType + provider_key: str + + def get_key(self, outcome: EventLifecycleOutcome) -> str: + return self.get_standard_key( + domain="source_code", + integration_name=self.provider_key, + interaction_type=str(self.interaction_type), + outcome=outcome, + ) diff --git a/src/sentry/integrations/utils/metrics.py b/src/sentry/integrations/utils/metrics.py index e04b0ccb6a8c2..672502bfd5cb1 100644 --- a/src/sentry/integrations/utils/metrics.py +++ b/src/sentry/integrations/utils/metrics.py @@ -152,8 +152,11 @@ def record_success(self) -> None: self._terminate(EventLifecycleOutcome.SUCCESS) - def record_failure(self, exc: BaseException | None = None) -> None: - """Record that the event halted in failure. + def record_failure( + self, exc: BaseException | None = None, data: dict[str, Any] | None = None + ) -> None: + """Record that the event halted in failure. Additional data may be passed + to be logged. There is no need to call this method directly if an exception is raised from inside the context. It will be called automatically when exiting the context @@ -165,6 +168,8 @@ def record_failure(self, exc: BaseException | None = None) -> None: `record_failure` on the context object. """ + if data: + self._extra.update(data) self._terminate(EventLifecycleOutcome.FAILURE, exc) def __enter__(self) -> Self: