Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -16,6 +16,7 @@
from sentry.integrations.api.bases.organization_integrations import (
CellOrganizationIntegrationBaseEndpoint,
)
from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType
from sentry.integrations.source_code_management.repository import RepositoryIntegration
from sentry.models.organization import Organization
from sentry.models.repository import Repository
Expand Down Expand Up @@ -110,20 +111,24 @@ def get(
search = request.GET.get("search")
accessible_only = request.GET.get("accessibleOnly", "false").lower() == "true"

try:
with install.record_event(
SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES
).capture() as lifecycle:
# This endpoint backs interactive repo pickers (the onboarding
# repo selector and the code-mappings path config form), so opt
# into parallel pagination for the latency win. Providers that
# don't implement it ignore the flag; internal/background callers
# of get_repositories keep the serial default.
repositories = install.get_repositories(
search,
accessible_only=accessible_only,
use_cache=accessible_only and bool(search),
parallel=True,
)
except (IntegrationError, IdentityNotValid) as e:
return self.respond({"detail": str(e)}, status=400)
try:
repositories = install.get_repositories(
search,
accessible_only=accessible_only,
use_cache=accessible_only and bool(search),
parallel=True,
)
Comment thread
giovanni-guidini marked this conversation as resolved.
Resolved
except (IntegrationError, IdentityNotValid) as e:
lifecycle.record_halt(e, create_issue=False)
return self.respond({"detail": str(e)}, status=400)

installable_only = request.GET.get("installableOnly", "false").lower() == "true"

Expand Down
55 changes: 33 additions & 22 deletions src/sentry/integrations/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
from sentry.integrations.source_code_management.metrics import (
SCMIntegrationInteractionEvent,
SCMIntegrationInteractionType,
get_current_scm_interaction_type,
normalize_scm_provider_key,
)
from sentry.integrations.source_code_management.repo_trees import RepoTreesClient
from sentry.integrations.source_code_management.repository import RepositoryClient
Expand Down Expand Up @@ -107,6 +109,7 @@
# first are fetched concurrently. Bounded to keep the fan-out comfortably under
# Github's secondary (concurrent request) rate limits.
PAGINATION_MAX_WORKERS = 10
GET_REPOSITORIES_METRIC = "integrations.source_code_management.get_repositories"

JWT_AUTH_ROUTES = ("/app/installations", "access_tokens")

Expand Down Expand Up @@ -761,29 +764,37 @@ def get_repos(
(see ``_get_with_pagination``). Only interactive callers opt in; the
background ``link_all_repos`` task keeps the serial default.
"""
with SCMIntegrationInteractionEvent(
interaction_type=SCMIntegrationInteractionType.GET_REPOSITORIES,
provider_key=self.integration_name,
integration_id=self.integration.id,
organization_id=self.organization_id,
).capture() as lifecycle:
try:
return self._get_with_pagination(
"/installation/repositories",
response_key="repositories",
page_number_limit=page_number_limit,
api_request_type=GitHubApiRequestType.GET_REPOSITORIES,
raise_on_page_limit=raise_on_page_limit,
parallel=parallel,
)
except ApiPaginationTruncated as e:
# Hitting the pagination cap is an expected signal.
# Record as halt so the lifecycle doesn't create a
# Sentry every time this is called.
lifecycle.record_halt(e, create_issue=False)
raise
parent_interaction_type = get_current_scm_interaction_type()
metric_tags = {
"integration_name": normalize_scm_provider_key(self.integration_name),
"parent_interaction_type": (
str(parent_interaction_type) if parent_interaction_type else "unattributed"
),
}
try:
repositories = self._get_with_pagination(
"/installation/repositories",
response_key="repositories",
page_number_limit=page_number_limit,
api_request_type=GitHubApiRequestType.GET_REPOSITORIES,
raise_on_page_limit=raise_on_page_limit,
parallel=parallel,
)
except ApiPaginationTruncated:
metrics.incr(GET_REPOSITORIES_METRIC, tags={**metric_tags, "result": "halted"})
raise
except Exception:
metrics.incr(GET_REPOSITORIES_METRIC, tags={**metric_tags, "result": "error"})
raise
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

def get_repos_cached(self, ttl: int = 300, parallel: bool = False) -> list[CachedRepo]:
metrics.incr(GET_REPOSITORIES_METRIC, tags={**metric_tags, "result": "success"})
return repositories

def get_repos_cached(
self,
ttl: int = 300,
parallel: bool = False,
) -> list[CachedRepo]:
"""
Return all repos accessible to this installation, cached in
Django cache for ``ttl`` seconds.
Expand Down
67 changes: 64 additions & 3 deletions src/sentry/integrations/source_code_management/metrics.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
from collections.abc import Mapping
from contextvars import ContextVar, Token
from enum import StrEnum
from typing import Any
from types import TracebackType
from typing import Any, Self

from attr import dataclass

from sentry.integrations.base import IntegrationDomain
from sentry.integrations.utils.metrics import IntegrationEventLifecycleMetric
from sentry.integrations.utils.metrics import (
IntegrationEventLifecycle,
IntegrationEventLifecycleMetric,
)
from sentry.models.commit import Commit
from sentry.models.project import Project
from sentry.models.repository import Repository
Expand Down Expand Up @@ -66,10 +71,56 @@ class SCMIntegrationInteractionType(StrEnum):
GET_RATE_LIMIT = "get_rate_limit"

# Repo Trees
GET_REPOSITORIES = "get_repositories"
GET_REPO_TREE = "get_repo_tree"


_current_scm_interaction_type: ContextVar[SCMIntegrationInteractionType | None] = ContextVar(
"current_scm_interaction_type", default=None
)


def get_current_scm_interaction_type() -> SCMIntegrationInteractionType | None:
return _current_scm_interaction_type.get()


class SCMIntegrationEventLifecycle(IntegrationEventLifecycle):
def __init__(
self,
payload: IntegrationEventLifecycleMetric,
interaction_type: SCMIntegrationInteractionType,
assume_success: bool,
sample_log_rate: float,
) -> None:
super().__init__(payload, assume_success, sample_log_rate)
self.interaction_type = interaction_type
self._interaction_token: Token[SCMIntegrationInteractionType | None] | None = None

def __enter__(self) -> Self:
if self._interaction_token is not None:
raise RuntimeError("SCM integration lifecycle cannot be re-entered")

self._interaction_token = _current_scm_interaction_type.set(self.interaction_type)
try:
return super().__enter__()
except BaseException:
_current_scm_interaction_type.reset(self._interaction_token)
self._interaction_token = None
raise

def __exit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType,
) -> None:
try:
super().__exit__(exc_type, exc_value, traceback)
finally:
if self._interaction_token is not None:
_current_scm_interaction_type.reset(self._interaction_token)
self._interaction_token = None


class SCMResolutionHaltReason(StrEnum):
GROUP_LINK_ALREADY_EXISTS = "group_link_already_exists"
ISSUE_ALREADY_RESOLVED = "issue_already_resolved"
Expand Down Expand Up @@ -98,6 +149,16 @@ def get_interaction_type(self) -> str:
def get_integration_id(self) -> int | None:
return self.integration_id

def capture(
self, assume_success: bool = True, sample_log_rate: float = 1.0
) -> SCMIntegrationEventLifecycle:
return SCMIntegrationEventLifecycle(
self,
self.interaction_type,
assume_success,
sample_log_rate,
)

def get_extras(self) -> Mapping[str, Any]:
return {
"organization_id": (self.organization_id if self.organization_id else None),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ def get_client(self) -> RepoTreesClient:
raise NotImplementedError

@abstractmethod
def get_repositories(self, query: str | None = None) -> list[RepositoryInfo]:
def get_repositories(
self,
query: str | None = None,
) -> list[RepositoryInfo]:
raise NotImplementedError

@property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ def get_repositories(
``parallel`` is a hint that pages after the first may be fetched
concurrently. Providers that do not implement parallel pagination may
accept and ignore this argument.

"""
raise NotImplementedError

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from unittest.mock import MagicMock, patch

from sentry.integrations.types import EventLifecycleOutcome
from sentry.shared_integrations.exceptions import IntegrationError
from sentry.testutils.cases import APITestCase


Expand All @@ -17,6 +19,23 @@ def setUp(self) -> None:
f"/api/0/organizations/{self.org.slug}/integrations/{self.integration.id}/repos/"
)

@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@patch(
"sentry.integrations.github.integration.GitHubIntegration.get_repositories",
side_effect=IntegrationError("Unable to retrieve repositories"),
)
def test_expected_error_halts_lifecycle(
self, _get_repositories: MagicMock, record_event: MagicMock
) -> None:
response = self.client.get(self.path, format="json")

assert response.status_code == 400
assert response.data == {"detail": "Unable to retrieve repositories"}
assert [call.args[0] for call in record_event.call_args_list] == [
EventLifecycleOutcome.STARTED,
EventLifecycleOutcome.HALTED,
]

@patch(
"sentry.integrations.github.integration.GitHubIntegration.get_repositories", return_value=[]
)
Expand Down
60 changes: 24 additions & 36 deletions tests/sentry/integrations/github/tasks/test_link_all_repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,10 @@ def test_link_all_repos(self, mock_record: MagicMock, _: MagicMock) -> None:
assert repos[0].name == "getsentry/sentry"
assert repos[1].name == "getsentry/snuba"

assert len(mock_record.mock_calls) == 4
start1, start2, end2, end1 = mock_record.mock_calls
assert start1.args[0] == EventLifecycleOutcome.STARTED
assert start2.args[0] == EventLifecycleOutcome.STARTED
assert end2.args[0] == EventLifecycleOutcome.SUCCESS
assert end1.args[0] == EventLifecycleOutcome.SUCCESS
assert len(mock_record.mock_calls) == 2
start, end = mock_record.mock_calls
assert start.args[0] == EventLifecycleOutcome.STARTED
assert end.args[0] == EventLifecycleOutcome.SUCCESS

@responses.activate
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
Expand Down Expand Up @@ -126,12 +124,10 @@ def test_link_all_repos_api_response_keyerror(

assert repos[0].name == "getsentry/snuba"

assert len(mock_record.mock_calls) == 4
start1, start2, end2, end1 = mock_record.mock_calls
assert start1.args[0] == EventLifecycleOutcome.STARTED
assert start2.args[0] == EventLifecycleOutcome.STARTED
assert end2.args[0] == EventLifecycleOutcome.SUCCESS
assert end1.args[0] == EventLifecycleOutcome.HALTED
assert len(mock_record.mock_calls) == 2
start, end = mock_record.mock_calls
assert start.args[0] == EventLifecycleOutcome.STARTED
assert end.args[0] == EventLifecycleOutcome.HALTED
assert_halt_metric(
mock_record, LinkAllReposHaltReason.REPOSITORY_NOT_CREATED.value
) # should be halt because it didn't complete successfully
Expand Down Expand Up @@ -165,12 +161,10 @@ def test_link_all_repos_api_response_keyerror_single_repo(
repos = Repository.objects.all()
assert len(repos) == 0

assert len(mock_record.mock_calls) == 4
start1, start2, end2, end1 = mock_record.mock_calls
assert start1.args[0] == EventLifecycleOutcome.STARTED
assert start2.args[0] == EventLifecycleOutcome.STARTED
assert end2.args[0] == EventLifecycleOutcome.SUCCESS
assert end1.args[0] == EventLifecycleOutcome.HALTED
assert len(mock_record.mock_calls) == 2
start, end = mock_record.mock_calls
assert start.args[0] == EventLifecycleOutcome.STARTED
assert end.args[0] == EventLifecycleOutcome.HALTED
assert_halt_metric(mock_record, LinkAllReposHaltReason.REPOSITORY_NOT_CREATED.value)

@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
Expand Down Expand Up @@ -213,12 +207,10 @@ def test_link_all_repos_api_error(self, mock_record: MagicMock, _: MagicMock) ->
organization_id=self.organization.id,
)

assert len(mock_record.mock_calls) == 4
start1, start2, end2, end1 = mock_record.mock_calls
assert start1.args[0] == EventLifecycleOutcome.STARTED
assert start2.args[0] == EventLifecycleOutcome.STARTED
assert end2.args[0] == EventLifecycleOutcome.FAILURE
assert end1.args[0] == EventLifecycleOutcome.FAILURE
assert len(mock_record.mock_calls) == 2
start, end = mock_record.mock_calls
assert start.args[0] == EventLifecycleOutcome.STARTED
assert end.args[0] == EventLifecycleOutcome.FAILURE

@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
@responses.activate
Expand All @@ -241,12 +233,10 @@ def test_link_all_repos_api_error_rate_limited(
organization_id=self.organization.id,
)

assert len(mock_record.mock_calls) == 4
start1, start2, end2, end1 = mock_record.mock_calls
assert start1.args[0] == EventLifecycleOutcome.STARTED
assert start2.args[0] == EventLifecycleOutcome.STARTED
assert end2.args[0] == EventLifecycleOutcome.FAILURE
assert end1.args[0] == EventLifecycleOutcome.HALTED
assert len(mock_record.mock_calls) == 2
start, end = mock_record.mock_calls
assert start.args[0] == EventLifecycleOutcome.STARTED
assert end.args[0] == EventLifecycleOutcome.HALTED
assert_halt_metric(mock_record, LinkAllReposHaltReason.RATE_LIMITED.value)

@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
Expand All @@ -265,10 +255,8 @@ def test_link_all_repos_repo_creation_error(
organization_id=self.organization.id,
)

assert len(mock_record.mock_calls) == 4
start1, start2, end2, end1 = mock_record.mock_calls
assert start1.args[0] == EventLifecycleOutcome.STARTED
assert start2.args[0] == EventLifecycleOutcome.STARTED
assert end2.args[0] == EventLifecycleOutcome.SUCCESS
assert end1.args[0] == EventLifecycleOutcome.HALTED
assert len(mock_record.mock_calls) == 2
start, end = mock_record.mock_calls
assert start.args[0] == EventLifecycleOutcome.STARTED
assert end.args[0] == EventLifecycleOutcome.HALTED
assert_halt_metric(mock_record, LinkAllReposHaltReason.REPOSITORY_NOT_CREATED.value)
Loading
Loading