Skip to content

Commit

Permalink
installation step metrics
Browse files Browse the repository at this point in the history
  • Loading branch information
cathteng committed Oct 3, 2024
1 parent 937ee13 commit bfdff76
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 107 deletions.
257 changes: 152 additions & 105 deletions src/sentry/integrations/github/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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()
44 changes: 44 additions & 0 deletions src/sentry/integrations/source_code_management/metrics.py
Original file line number Diff line number Diff line change
@@ -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,
)
9 changes: 7 additions & 2 deletions src/sentry/integrations/utils/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down

0 comments on commit bfdff76

Please sign in to comment.