From a85b6280d1ad6fe365190358c60350c7e2a51dc2 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Thu, 29 Oct 2026 14:50:01 +0100 Subject: [PATCH 1/5] ref(integrations): Separate SCM repository list metrics Refs SCM-304 --- .../organization_integration_repos.py | 2 + .../integrations/bitbucket/integration.py | 3 + .../bitbucket_server/integration.py | 3 + .../integrations/cursor_origin/integration.py | 3 + .../integrations/example/integration.py | 3 + src/sentry/integrations/github/client.py | 59 +++++++++++------- src/sentry/integrations/github/integration.py | 9 ++- src/sentry/integrations/github/search.py | 7 ++- .../github/tasks/link_all_repos.py | 4 +- .../github_enterprise/integration.py | 4 ++ src/sentry/integrations/gitlab/integration.py | 3 + .../integrations/perforce/integration.py | 3 + .../source_code_management/issues.py | 5 +- .../source_code_management/metrics.py | 1 - .../source_code_management/repo_trees.py | 12 +++- .../source_code_management/repository.py | 6 ++ .../source_code_management/sync_repos.py | 5 +- src/sentry/integrations/vsts/integration.py | 3 + .../test_organization_integration_repos.py | 25 ++++++-- .../github/tasks/test_link_all_repos.py | 60 ++++++++----------- .../integrations/github/test_integration.py | 54 ++++++++++++++++- 21 files changed, 201 insertions(+), 73 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/organization_integration_repos.py b/src/sentry/integrations/api/endpoints/organization_integration_repos.py index 58e32275ea57..25d3d4e24345 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_repos.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_repos.py @@ -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 @@ -121,6 +122,7 @@ def get( accessible_only=accessible_only, use_cache=accessible_only and bool(search), parallel=True, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, ) except (IntegrationError, IdentityNotValid) as e: return self.respond({"detail": str(e)}, status=400) diff --git a/src/sentry/integrations/bitbucket/integration.py b/src/sentry/integrations/bitbucket/integration.py index 6f2611de8cad..f3fc55272d6d 100644 --- a/src/sentry/integrations/bitbucket/integration.py +++ b/src/sentry/integrations/bitbucket/integration.py @@ -21,6 +21,7 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import RpcRepository, repository_service +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( RepositoryInfo, RepositoryIntegration, @@ -132,6 +133,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: username = self.model.metadata.get("uuid", self.username) if not query: diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 9919d5e3f93f..58a3fcbd1e42 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -24,6 +24,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import repository_service from sentry.integrations.services.repository.model import RpcRepository +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -290,6 +291,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: if not query: resp = self.get_client().get_repos() diff --git a/src/sentry/integrations/cursor_origin/integration.py b/src/sentry/integrations/cursor_origin/integration.py index 998038bd626f..464f4e002e39 100644 --- a/src/sentry/integrations/cursor_origin/integration.py +++ b/src/sentry/integrations/cursor_origin/integration.py @@ -31,6 +31,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository.model import RpcRepository +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration from sentry.integrations.source_code_management.repository import ( HaltReason, @@ -79,6 +80,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """Repositories this installation can see, narrowed by `query` where given. diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index 296d84e37085..c3cd9a0f469e 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -21,6 +21,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.source_code_management.issues import SourceCodeIssueIntegration +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( RepositoryInfo, RepositoryIntegration, @@ -169,6 +170,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: return [{"name": "repo", "identifier": "user/repo", "external_id": "1"}] diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index ffa31a5342eb..0dcf1f79d2eb 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -39,6 +39,7 @@ from sentry.integrations.source_code_management.metrics import ( SCMIntegrationInteractionEvent, SCMIntegrationInteractionType, + normalize_scm_provider_key, ) from sentry.integrations.source_code_management.repo_trees import RepoTreesClient from sentry.integrations.source_code_management.repository import RepositoryClient @@ -107,6 +108,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") @@ -745,6 +747,8 @@ def get_repos( page_number_limit: int | None = None, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[dict[str, Any]]: """ This fetches all repositories accessible to the Github App @@ -761,29 +765,35 @@ 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 + 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 Exception: + metrics.incr(GET_REPOSITORIES_METRIC, tags={**metric_tags, "result": "error"}) + raise + + 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]: + def get_repos_cached( + self, + ttl: int = 300, + parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, + ) -> list[CachedRepo]: """ Return all repos accessible to this installation, cached in Django cache for ``ttl`` seconds. @@ -798,7 +808,10 @@ def get_repos_cached(self, ttl: int = 300, parallel: bool = False) -> list[Cache if cached is not None: return cached - all_repos = self.get_repos(parallel=parallel) + all_repos = self.get_repos( + parallel=parallel, + parent_interaction_type=parent_interaction_type, + ) repos: list[CachedRepo] = [ { "id": r["id"], diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index 379bc755095e..e4d0a8ec3cbf 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -44,6 +44,7 @@ CommitContextIntegration, PRCommentWorkflow, ) +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration from sentry.integrations.source_code_management.repository import ( HaltReason, @@ -295,6 +296,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """ args: @@ -339,12 +342,16 @@ def _process(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: def _fetch_and_process() -> list[RepositoryInfo]: try: raw = ( - client.get_repos_cached(parallel=parallel) + client.get_repos_cached( + parallel=parallel, + parent_interaction_type=parent_interaction_type, + ) if use_cache else client.get_repos( page_number_limit=page_number_limit, raise_on_page_limit=raise_on_page_limit, parallel=parallel, + parent_interaction_type=parent_interaction_type, ) ) except ApiPaginationTruncated as e: diff --git a/src/sentry/integrations/github/search.py b/src/sentry/integrations/github/search.py index 05f672f9edfa..cd003c53953e 100644 --- a/src/sentry/integrations/github/search.py +++ b/src/sentry/integrations/github/search.py @@ -72,7 +72,12 @@ def handle_search_repositories( try: if not query: - repositories = installation.get_repositories(page_number_limit=PAGE_LIMIT) + repositories = installation.get_repositories( + page_number_limit=PAGE_LIMIT, + parent_interaction_type=( + SCMIntegrationInteractionType.HANDLE_SEARCH_REPOSITORIES + ), + ) return Response( [ {"label": repository["name"], "value": repository["identifier"]} diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index f0d7eb038ad5..e9251e90581a 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -72,7 +72,9 @@ def link_all_repos( client = installation.get_client() try: - repositories = client.get_repos() + repositories = client.get_repos( + parent_interaction_type=SCMIntegrationInteractionType.LINK_ALL_REPOS + ) except ApiError as e: if installation.is_rate_limited_error(e): lifecycle.record_halt(str(LinkAllReposHaltReason.RATE_LIMITED)) diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 69481eca614e..776f3dbe284e 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -37,6 +37,7 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -260,6 +261,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: def _process(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: return [ @@ -278,6 +281,7 @@ def _process(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: all_repos = self.get_client().get_repos( page_number_limit=page_number_limit, raise_on_page_limit=raise_on_page_limit, + parent_interaction_type=parent_interaction_type, ) except ApiPaginationTruncated as e: # Transform partial data into RepositoryInfo before re-raising diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index e860ed5dbc99..652ab3133985 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -36,6 +36,7 @@ CommitContextIntegration, PRCommentWorkflow, ) +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -216,6 +217,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: try: # Note: gitlab projects are the same things as repos everywhere else diff --git a/src/sentry/integrations/perforce/integration.py b/src/sentry/integrations/perforce/integration.py index b0d3fae7c32c..3d35e8880726 100644 --- a/src/sentry/integrations/perforce/integration.py +++ b/src/sentry/integrations/perforce/integration.py @@ -33,6 +33,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( RepositoryInfo, RepositoryIntegration, @@ -429,6 +430,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """ Get list of depots/streams from Perforce server. diff --git a/src/sentry/integrations/source_code_management/issues.py b/src/sentry/integrations/source_code_management/issues.py index 8905f7e76d63..f4d1eea10492 100644 --- a/src/sentry/integrations/source_code_management/issues.py +++ b/src/sentry/integrations/source_code_management/issues.py @@ -37,7 +37,10 @@ def _get_repository_choices( page_number_limit: int | None = None, ) -> tuple[str, list[tuple[str, str]]]: try: - repos = self.get_repositories(page_number_limit=page_number_limit) + repos = self.get_repositories( + page_number_limit=page_number_limit, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, + ) except ApiError as exc: if any(pattern in str(exc) for pattern in SOURCE_CODE_ISSUE_HALT_PATTERNS): lifecycle.record_halt(exc) diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index 6ccb852b367c..43767dade462 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -66,7 +66,6 @@ class SCMIntegrationInteractionType(StrEnum): GET_RATE_LIMIT = "get_rate_limit" # Repo Trees - GET_REPOSITORIES = "get_repositories" GET_REPO_TREE = "get_repo_tree" diff --git a/src/sentry/integrations/source_code_management/repo_trees.py b/src/sentry/integrations/source_code_management/repo_trees.py index a2498bc0712f..5268cef67890 100644 --- a/src/sentry/integrations/source_code_management/repo_trees.py +++ b/src/sentry/integrations/source_code_management/repo_trees.py @@ -6,6 +6,7 @@ from typing import Any, NamedTuple from sentry.integrations.services.integration import RpcOrganizationIntegration +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import RepositoryInfo from sentry.issues.auto_source_code_config.utils.platform import get_supported_extensions from sentry.shared_integrations.exceptions import ApiConflictError, ApiError, IntegrationError @@ -138,7 +139,12 @@ 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, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, + ) -> list[RepositoryInfo]: raise NotImplementedError @property @@ -180,7 +186,9 @@ def _populate_repositories(self) -> list[dict[str, str]]: "default_branch": repo_info.get("default_branch") or "", "external_id": repo_info["external_id"], } - for repo_info in self.get_repositories() + for repo_info in self.get_repositories( + parent_interaction_type=SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS + ) if not repo_info.get("archived") ] diff --git a/src/sentry/integrations/source_code_management/repository.py b/src/sentry/integrations/source_code_management/repository.py index a1ad02993db0..d79c17e887de 100644 --- a/src/sentry/integrations/source_code_management/repository.py +++ b/src/sentry/integrations/source_code_management/repository.py @@ -86,6 +86,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """ Get a list of available repositories for an installation @@ -119,6 +121,10 @@ 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. + + ``parent_interaction_type`` attributes operational repository-list + telemetry to the complete SCM feature that requested it. Providers + without operational telemetry may accept and ignore this argument. """ raise NotImplementedError diff --git a/src/sentry/integrations/source_code_management/sync_repos.py b/src/sentry/integrations/source_code_management/sync_repos.py index 2c726ece2614..79dc901e15b8 100644 --- a/src/sentry/integrations/source_code_management/sync_repos.py +++ b/src/sentry/integrations/source_code_management/sync_repos.py @@ -175,7 +175,10 @@ def _sync_repos_for_org(organization_integration_id: int) -> None: fetch_truncated = False try: - provider_repos = installation.get_repositories(raise_on_page_limit=True) + provider_repos = installation.get_repositories( + raise_on_page_limit=True, + parent_interaction_type=SCMIntegrationInteractionType.SYNC_REPOS, + ) except ApiPaginationTruncated as e: # Provider fetch hit a pagination cap with more data still # available. We keep the partial result for create/restore — those diff --git a/src/sentry/integrations/vsts/integration.py b/src/sentry/integrations/vsts/integration.py index da42736990aa..209b30568242 100644 --- a/src/sentry/integrations/vsts/integration.py +++ b/src/sentry/integrations/vsts/integration.py @@ -34,6 +34,7 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository, repository_service +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -358,6 +359,8 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, + *, + parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: try: repos = self.get_client().get_repos() diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py index 2454afd63510..7990a14375d9 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py @@ -1,5 +1,6 @@ from unittest.mock import MagicMock, patch +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.testutils.cases import APITestCase @@ -263,7 +264,11 @@ def test_accessible_only_passes_param(self, get_repositories: MagicMock) -> None assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - "rad", accessible_only=True, use_cache=True, parallel=True + "rad", + accessible_only=True, + use_cache=True, + parallel=True, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, ) assert response.data == { "repos": [ @@ -296,7 +301,11 @@ def test_accessible_only_without_search(self, get_repositories: MagicMock) -> No assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - None, accessible_only=True, use_cache=False, parallel=True + None, + accessible_only=True, + use_cache=False, + parallel=True, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, ) @patch( @@ -308,7 +317,11 @@ def test_passes_parallel(self, get_repositories: MagicMock) -> None: assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - None, accessible_only=False, use_cache=False, parallel=True + None, + accessible_only=False, + use_cache=False, + parallel=True, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, ) @patch( @@ -346,7 +359,11 @@ def test_accessible_only_with_installable_only(self, get_repositories: MagicMock assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - "Example", accessible_only=True, use_cache=True, parallel=True + "Example", + accessible_only=True, + use_cache=True, + parallel=True, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, ) assert response.data == { "repos": [ diff --git a/tests/sentry/integrations/github/tasks/test_link_all_repos.py b/tests/sentry/integrations/github/tasks/test_link_all_repos.py index 36ce4e50d2bd..5c2a271d5f9c 100644 --- a/tests/sentry/integrations/github/tasks/test_link_all_repos.py +++ b/tests/sentry/integrations/github/tasks/test_link_all_repos.py @@ -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") @@ -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 @@ -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") @@ -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 @@ -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") @@ -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) diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py index 9640a324a743..4254fad4bb70 100644 --- a/tests/sentry/integrations/github/test_integration.py +++ b/tests/sentry/integrations/github/test_integration.py @@ -36,6 +36,7 @@ FileBlameInfo, SourceLineInfo, ) +from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repo_trees import RepoAndBranch, RepoTree from sentry.integrations.types import ExternalProviders from sentry.models.repository import Repository @@ -551,8 +552,14 @@ def test_get_repositories_all_and_pagination(self) -> None: GitHubIntegration, integration, self.organization.id ) - with patch.object(client.GitHubBaseClient, "page_size", 1): - result = installation.get_repositories(parallel=True) + with ( + patch.object(client.GitHubBaseClient, "page_size", 1), + patch("sentry.integrations.github.client.metrics.incr") as mock_incr, + ): + result = installation.get_repositories( + parallel=True, + parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, + ) assert result == [ { "name": "foo", @@ -573,6 +580,49 @@ def test_get_repositories_all_and_pagination(self) -> None: "default_branch": "master", }, ] + assert ( + mock.call( + client.GET_REPOSITORIES_METRIC, + tags={ + "integration_name": "github", + "parent_interaction_type": "get_repository_choices", + "result": "success", + }, + ) + in mock_incr.mock_calls + ) + + def test_get_repositories_records_error_metric(self) -> None: + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + installation = get_installation_of_type( + GitHubIntegration, integration, self.organization.id + ) + + with ( + patch.object( + client.GitHubBaseClient, + "_get_with_pagination", + side_effect=ApiError("GitHub API error"), + ), + patch("sentry.integrations.github.client.metrics.incr") as mock_incr, + pytest.raises(ApiError), + ): + installation.get_repositories( + parent_interaction_type=SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS + ) + + assert ( + mock.call( + client.GET_REPOSITORIES_METRIC, + tags={ + "integration_name": "github", + "parent_interaction_type": "derive_codemappings", + "result": "error", + }, + ) + in mock_incr.mock_calls + ) @responses.activate def test_get_repositories_only_first_page(self) -> None: From 5a9ebe4bc66827e8ebb1ded47288252d7777ff30 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Thu, 29 Oct 2026 17:11:26 +0100 Subject: [PATCH 2/5] ref(integrations): Read parent feature from lifecycle context Refs SCM-304 --- .../organization_integration_repos.py | 16 ++--- .../integrations/bitbucket/integration.py | 3 - .../bitbucket_server/integration.py | 3 - .../integrations/cursor_origin/integration.py | 3 - .../integrations/example/integration.py | 3 - src/sentry/integrations/github/client.py | 14 ++--- src/sentry/integrations/github/integration.py | 9 +-- src/sentry/integrations/github/search.py | 7 +-- .../github/tasks/link_all_repos.py | 4 +- .../github_enterprise/integration.py | 4 -- src/sentry/integrations/gitlab/integration.py | 3 - .../integrations/perforce/integration.py | 3 - .../source_code_management/issues.py | 5 +- .../source_code_management/metrics.py | 63 ++++++++++++++++++- .../source_code_management/repo_trees.py | 7 +-- .../source_code_management/repository.py | 5 -- .../source_code_management/sync_repos.py | 5 +- src/sentry/integrations/vsts/integration.py | 3 - .../test_organization_integration_repos.py | 25 ++------ .../integrations/github/test_integration.py | 48 +++++++++++--- .../test_commit_context.py | 38 +++++++++++ 21 files changed, 164 insertions(+), 107 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/organization_integration_repos.py b/src/sentry/integrations/api/endpoints/organization_integration_repos.py index 25d3d4e24345..287feff9ef83 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_repos.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_repos.py @@ -117,13 +117,15 @@ def get( # 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, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, - ) + with install.record_event( + SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES + ).capture(): + 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) diff --git a/src/sentry/integrations/bitbucket/integration.py b/src/sentry/integrations/bitbucket/integration.py index f3fc55272d6d..6f2611de8cad 100644 --- a/src/sentry/integrations/bitbucket/integration.py +++ b/src/sentry/integrations/bitbucket/integration.py @@ -21,7 +21,6 @@ from sentry.integrations.models.integration import Integration from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import RpcRepository, repository_service -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( RepositoryInfo, RepositoryIntegration, @@ -133,8 +132,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: username = self.model.metadata.get("uuid", self.username) if not query: diff --git a/src/sentry/integrations/bitbucket_server/integration.py b/src/sentry/integrations/bitbucket_server/integration.py index 58a3fcbd1e42..9919d5e3f93f 100644 --- a/src/sentry/integrations/bitbucket_server/integration.py +++ b/src/sentry/integrations/bitbucket_server/integration.py @@ -24,7 +24,6 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import repository_service from sentry.integrations.services.repository.model import RpcRepository -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -291,8 +290,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: if not query: resp = self.get_client().get_repos() diff --git a/src/sentry/integrations/cursor_origin/integration.py b/src/sentry/integrations/cursor_origin/integration.py index 464f4e002e39..998038bd626f 100644 --- a/src/sentry/integrations/cursor_origin/integration.py +++ b/src/sentry/integrations/cursor_origin/integration.py @@ -31,7 +31,6 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository.model import RpcRepository -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration from sentry.integrations.source_code_management.repository import ( HaltReason, @@ -80,8 +79,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """Repositories this installation can see, narrowed by `query` where given. diff --git a/src/sentry/integrations/example/integration.py b/src/sentry/integrations/example/integration.py index c3cd9a0f469e..296d84e37085 100644 --- a/src/sentry/integrations/example/integration.py +++ b/src/sentry/integrations/example/integration.py @@ -21,7 +21,6 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository.model import RpcRepository from sentry.integrations.source_code_management.issues import SourceCodeIssueIntegration -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( RepositoryInfo, RepositoryIntegration, @@ -170,8 +169,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: return [{"name": "repo", "identifier": "user/repo", "external_id": "1"}] diff --git a/src/sentry/integrations/github/client.py b/src/sentry/integrations/github/client.py index 0dcf1f79d2eb..1f96b0721327 100644 --- a/src/sentry/integrations/github/client.py +++ b/src/sentry/integrations/github/client.py @@ -39,6 +39,7 @@ 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 @@ -747,8 +748,6 @@ def get_repos( page_number_limit: int | None = None, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[dict[str, Any]]: """ This fetches all repositories accessible to the Github App @@ -765,6 +764,7 @@ def get_repos( (see ``_get_with_pagination``). Only interactive callers opt in; the background ``link_all_repos`` task keeps the serial default. """ + parent_interaction_type = get_current_scm_interaction_type() metric_tags = { "integration_name": normalize_scm_provider_key(self.integration_name), "parent_interaction_type": ( @@ -780,6 +780,9 @@ def get_repos( 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 @@ -791,8 +794,6 @@ def get_repos_cached( self, ttl: int = 300, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[CachedRepo]: """ Return all repos accessible to this installation, cached in @@ -808,10 +809,7 @@ def get_repos_cached( if cached is not None: return cached - all_repos = self.get_repos( - parallel=parallel, - parent_interaction_type=parent_interaction_type, - ) + all_repos = self.get_repos(parallel=parallel) repos: list[CachedRepo] = [ { "id": r["id"], diff --git a/src/sentry/integrations/github/integration.py b/src/sentry/integrations/github/integration.py index e4d0a8ec3cbf..379bc755095e 100644 --- a/src/sentry/integrations/github/integration.py +++ b/src/sentry/integrations/github/integration.py @@ -44,7 +44,6 @@ CommitContextIntegration, PRCommentWorkflow, ) -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repo_trees import RepoTreesIntegration from sentry.integrations.source_code_management.repository import ( HaltReason, @@ -296,8 +295,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """ args: @@ -342,16 +339,12 @@ def _process(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: def _fetch_and_process() -> list[RepositoryInfo]: try: raw = ( - client.get_repos_cached( - parallel=parallel, - parent_interaction_type=parent_interaction_type, - ) + client.get_repos_cached(parallel=parallel) if use_cache else client.get_repos( page_number_limit=page_number_limit, raise_on_page_limit=raise_on_page_limit, parallel=parallel, - parent_interaction_type=parent_interaction_type, ) ) except ApiPaginationTruncated as e: diff --git a/src/sentry/integrations/github/search.py b/src/sentry/integrations/github/search.py index cd003c53953e..05f672f9edfa 100644 --- a/src/sentry/integrations/github/search.py +++ b/src/sentry/integrations/github/search.py @@ -72,12 +72,7 @@ def handle_search_repositories( try: if not query: - repositories = installation.get_repositories( - page_number_limit=PAGE_LIMIT, - parent_interaction_type=( - SCMIntegrationInteractionType.HANDLE_SEARCH_REPOSITORIES - ), - ) + repositories = installation.get_repositories(page_number_limit=PAGE_LIMIT) return Response( [ {"label": repository["name"], "value": repository["identifier"]} diff --git a/src/sentry/integrations/github/tasks/link_all_repos.py b/src/sentry/integrations/github/tasks/link_all_repos.py index e9251e90581a..f0d7eb038ad5 100644 --- a/src/sentry/integrations/github/tasks/link_all_repos.py +++ b/src/sentry/integrations/github/tasks/link_all_repos.py @@ -72,9 +72,7 @@ def link_all_repos( client = installation.get_client() try: - repositories = client.get_repos( - parent_interaction_type=SCMIntegrationInteractionType.LINK_ALL_REPOS - ) + repositories = client.get_repos() except ApiError as e: if installation.is_rate_limited_error(e): lifecycle.record_halt(str(LinkAllReposHaltReason.RATE_LIMITED)) diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index 776f3dbe284e..69481eca614e 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -37,7 +37,6 @@ from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -261,8 +260,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: def _process(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: return [ @@ -281,7 +278,6 @@ def _process(raw_repos: Iterable[Mapping[str, Any]]) -> list[RepositoryInfo]: all_repos = self.get_client().get_repos( page_number_limit=page_number_limit, raise_on_page_limit=raise_on_page_limit, - parent_interaction_type=parent_interaction_type, ) except ApiPaginationTruncated as e: # Transform partial data into RepositoryInfo before re-raising diff --git a/src/sentry/integrations/gitlab/integration.py b/src/sentry/integrations/gitlab/integration.py index 652ab3133985..e860ed5dbc99 100644 --- a/src/sentry/integrations/gitlab/integration.py +++ b/src/sentry/integrations/gitlab/integration.py @@ -36,7 +36,6 @@ CommitContextIntegration, PRCommentWorkflow, ) -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -217,8 +216,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: try: # Note: gitlab projects are the same things as repos everywhere else diff --git a/src/sentry/integrations/perforce/integration.py b/src/sentry/integrations/perforce/integration.py index 3d35e8880726..b0d3fae7c32c 100644 --- a/src/sentry/integrations/perforce/integration.py +++ b/src/sentry/integrations/perforce/integration.py @@ -33,7 +33,6 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.repository import RpcRepository from sentry.integrations.source_code_management.commit_context import CommitContextIntegration -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( RepositoryInfo, RepositoryIntegration, @@ -430,8 +429,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """ Get list of depots/streams from Perforce server. diff --git a/src/sentry/integrations/source_code_management/issues.py b/src/sentry/integrations/source_code_management/issues.py index f4d1eea10492..8905f7e76d63 100644 --- a/src/sentry/integrations/source_code_management/issues.py +++ b/src/sentry/integrations/source_code_management/issues.py @@ -37,10 +37,7 @@ def _get_repository_choices( page_number_limit: int | None = None, ) -> tuple[str, list[tuple[str, str]]]: try: - repos = self.get_repositories( - page_number_limit=page_number_limit, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, - ) + repos = self.get_repositories(page_number_limit=page_number_limit) except ApiError as exc: if any(pattern in str(exc) for pattern in SOURCE_CODE_ISSUE_HALT_PATTERNS): lifecycle.record_halt(exc) diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index 43767dade462..b64fffaa5053 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -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 @@ -69,6 +74,50 @@ class SCMIntegrationInteractionType(StrEnum): 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: + 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" @@ -97,6 +146,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), diff --git a/src/sentry/integrations/source_code_management/repo_trees.py b/src/sentry/integrations/source_code_management/repo_trees.py index 5268cef67890..480df1b88b2a 100644 --- a/src/sentry/integrations/source_code_management/repo_trees.py +++ b/src/sentry/integrations/source_code_management/repo_trees.py @@ -6,7 +6,6 @@ from typing import Any, NamedTuple from sentry.integrations.services.integration import RpcOrganizationIntegration -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import RepositoryInfo from sentry.issues.auto_source_code_config.utils.platform import get_supported_extensions from sentry.shared_integrations.exceptions import ApiConflictError, ApiError, IntegrationError @@ -142,8 +141,6 @@ def get_client(self) -> RepoTreesClient: def get_repositories( self, query: str | None = None, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: raise NotImplementedError @@ -186,9 +183,7 @@ def _populate_repositories(self) -> list[dict[str, str]]: "default_branch": repo_info.get("default_branch") or "", "external_id": repo_info["external_id"], } - for repo_info in self.get_repositories( - parent_interaction_type=SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS - ) + for repo_info in self.get_repositories() if not repo_info.get("archived") ] diff --git a/src/sentry/integrations/source_code_management/repository.py b/src/sentry/integrations/source_code_management/repository.py index d79c17e887de..a8b2cc3967f7 100644 --- a/src/sentry/integrations/source_code_management/repository.py +++ b/src/sentry/integrations/source_code_management/repository.py @@ -86,8 +86,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: """ Get a list of available repositories for an installation @@ -122,9 +120,6 @@ def get_repositories( concurrently. Providers that do not implement parallel pagination may accept and ignore this argument. - ``parent_interaction_type`` attributes operational repository-list - telemetry to the complete SCM feature that requested it. Providers - without operational telemetry may accept and ignore this argument. """ raise NotImplementedError diff --git a/src/sentry/integrations/source_code_management/sync_repos.py b/src/sentry/integrations/source_code_management/sync_repos.py index 79dc901e15b8..2c726ece2614 100644 --- a/src/sentry/integrations/source_code_management/sync_repos.py +++ b/src/sentry/integrations/source_code_management/sync_repos.py @@ -175,10 +175,7 @@ def _sync_repos_for_org(organization_integration_id: int) -> None: fetch_truncated = False try: - provider_repos = installation.get_repositories( - raise_on_page_limit=True, - parent_interaction_type=SCMIntegrationInteractionType.SYNC_REPOS, - ) + provider_repos = installation.get_repositories(raise_on_page_limit=True) except ApiPaginationTruncated as e: # Provider fetch hit a pagination cap with more data still # available. We keep the partial result for create/restore — those diff --git a/src/sentry/integrations/vsts/integration.py b/src/sentry/integrations/vsts/integration.py index 209b30568242..da42736990aa 100644 --- a/src/sentry/integrations/vsts/integration.py +++ b/src/sentry/integrations/vsts/integration.py @@ -34,7 +34,6 @@ from sentry.integrations.pipeline import IntegrationPipeline from sentry.integrations.services.integration import integration_service from sentry.integrations.services.repository import RpcRepository, repository_service -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.integrations.source_code_management.repository import ( HaltReason, RepositoryInfo, @@ -359,8 +358,6 @@ def get_repositories( use_cache: bool = False, raise_on_page_limit: bool = False, parallel: bool = False, - *, - parent_interaction_type: SCMIntegrationInteractionType | None = None, ) -> list[RepositoryInfo]: try: repos = self.get_client().get_repos() diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py index 7990a14375d9..2454afd63510 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, patch -from sentry.integrations.source_code_management.metrics import SCMIntegrationInteractionType from sentry.testutils.cases import APITestCase @@ -264,11 +263,7 @@ def test_accessible_only_passes_param(self, get_repositories: MagicMock) -> None assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - "rad", - accessible_only=True, - use_cache=True, - parallel=True, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, + "rad", accessible_only=True, use_cache=True, parallel=True ) assert response.data == { "repos": [ @@ -301,11 +296,7 @@ def test_accessible_only_without_search(self, get_repositories: MagicMock) -> No assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - None, - accessible_only=True, - use_cache=False, - parallel=True, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, + None, accessible_only=True, use_cache=False, parallel=True ) @patch( @@ -317,11 +308,7 @@ def test_passes_parallel(self, get_repositories: MagicMock) -> None: assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - None, - accessible_only=False, - use_cache=False, - parallel=True, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, + None, accessible_only=False, use_cache=False, parallel=True ) @patch( @@ -359,11 +346,7 @@ def test_accessible_only_with_installable_only(self, get_repositories: MagicMock assert response.status_code == 200, response.content get_repositories.assert_called_once_with( - "Example", - accessible_only=True, - use_cache=True, - parallel=True, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, + "Example", accessible_only=True, use_cache=True, parallel=True ) assert response.data == { "repos": [ diff --git a/tests/sentry/integrations/github/test_integration.py b/tests/sentry/integrations/github/test_integration.py index 4254fad4bb70..90151ff7a54f 100644 --- a/tests/sentry/integrations/github/test_integration.py +++ b/tests/sentry/integrations/github/test_integration.py @@ -40,7 +40,7 @@ from sentry.integrations.source_code_management.repo_trees import RepoAndBranch, RepoTree from sentry.integrations.types import ExternalProviders from sentry.models.repository import Repository -from sentry.shared_integrations.exceptions import ApiError +from sentry.shared_integrations.exceptions import ApiError, ApiPaginationTruncated from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, IntegrationTestCase from sentry.testutils.helpers import with_feature @@ -556,10 +556,10 @@ def test_get_repositories_all_and_pagination(self) -> None: patch.object(client.GitHubBaseClient, "page_size", 1), patch("sentry.integrations.github.client.metrics.incr") as mock_incr, ): - result = installation.get_repositories( - parallel=True, - parent_interaction_type=SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES, - ) + with installation.record_event( + SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES + ).capture(): + result = installation.get_repositories(parallel=True) assert result == [ { "name": "foo", @@ -608,9 +608,10 @@ def test_get_repositories_records_error_metric(self) -> None: patch("sentry.integrations.github.client.metrics.incr") as mock_incr, pytest.raises(ApiError), ): - installation.get_repositories( - parent_interaction_type=SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS - ) + with installation.record_event( + SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS + ).capture(): + installation.get_repositories() assert ( mock.call( @@ -624,6 +625,37 @@ def test_get_repositories_records_error_metric(self) -> None: in mock_incr.mock_calls ) + def test_get_repositories_records_halted_metric_when_truncated(self) -> None: + self.assert_setup_flow() + integration = Integration.objects.get(provider=self.provider.key) + installation = get_installation_of_type( + GitHubIntegration, integration, self.organization.id + ) + + with ( + patch.object( + client.GitHubBaseClient, + "_get_with_pagination", + side_effect=ApiPaginationTruncated(partial_data=[]), + ), + patch("sentry.integrations.github.client.metrics.incr") as mock_incr, + pytest.raises(ApiPaginationTruncated), + ): + with installation.record_event(SCMIntegrationInteractionType.SYNC_REPOS).capture(): + installation.get_repositories(raise_on_page_limit=True) + + assert ( + mock.call( + client.GET_REPOSITORIES_METRIC, + tags={ + "integration_name": "github", + "parent_interaction_type": "sync_repos", + "result": "halted", + }, + ) + in mock_incr.mock_calls + ) + @responses.activate def test_get_repositories_only_first_page(self) -> None: """Fetch all repositories and test the pagination logic.""" diff --git a/tests/sentry/integrations/source_code_management/test_commit_context.py b/tests/sentry/integrations/source_code_management/test_commit_context.py index 8e3cfbfbe2eb..526992de7969 100644 --- a/tests/sentry/integrations/source_code_management/test_commit_context.py +++ b/tests/sentry/integrations/source_code_management/test_commit_context.py @@ -14,6 +14,7 @@ from sentry.integrations.source_code_management.metrics import ( SCMIntegrationInteractionEvent, SCMIntegrationInteractionType, + get_current_scm_interaction_type, ) from sentry.integrations.types import EventLifecycleOutcome from sentry.models.repository import Repository @@ -47,6 +48,43 @@ def test_scm_interaction_event_normalizes_provider( } +@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") +def test_scm_interaction_context_tracks_nested_lifecycles(_mock_record: MagicMock) -> None: + outer = SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS, + provider_key="github", + ) + inner = SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.GET_REPO_TREE, + provider_key="github", + ) + + assert get_current_scm_interaction_type() is None + with outer.capture(): + assert ( + get_current_scm_interaction_type() == SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS + ) + with inner.capture(): + assert get_current_scm_interaction_type() == SCMIntegrationInteractionType.GET_REPO_TREE + assert ( + get_current_scm_interaction_type() == SCMIntegrationInteractionType.DERIVE_CODEMAPPINGS + ) + assert get_current_scm_interaction_type() is None + + +@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") +def test_scm_interaction_context_resets_after_exception(_mock_record: MagicMock) -> None: + event = SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.SYNC_REPOS, + provider_key="github", + ) + + with pytest.raises(RuntimeError), event.capture(): + raise RuntimeError + + assert get_current_scm_interaction_type() is None + + class MockCommitContextIntegration(CommitContextIntegration): """Mock implementation for testing""" From a0132bfcf93c9f2494cae78366480e8e84b955a4 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Thu, 29 Oct 2026 17:29:23 +0100 Subject: [PATCH 3/5] fix(integrations): Halt expected repository picker errors Refs SCM-304 --- .../organization_integration_repos.py | 17 +++++++++++------ .../test_organization_integration_repos.py | 19 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/organization_integration_repos.py b/src/sentry/integrations/api/endpoints/organization_integration_repos.py index 287feff9ef83..b4ff60a987d6 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_repos.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_repos.py @@ -111,23 +111,28 @@ def get( search = request.GET.get("search") accessible_only = request.GET.get("accessibleOnly", "false").lower() == "true" - try: + user_facing_error = None + 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. - with install.record_event( - SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES - ).capture(): + try: 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) + except (IntegrationError, IdentityNotValid) as e: + lifecycle.record_halt(e, create_issue=False) + user_facing_error = e + + if user_facing_error is not None: + return self.respond({"detail": str(user_facing_error)}, status=400) installable_only = request.GET.get("installableOnly", "false").lower() == "true" diff --git a/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py b/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py index 2454afd63510..8483defcdbac 100644 --- a/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py +++ b/tests/sentry/integrations/api/endpoints/test_organization_integration_repos.py @@ -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 @@ -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=[] ) From 46ab6023ea959f94af74b53d733bf3ae598e6d13 Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Mon, 2 Nov 2026 09:31:16 +0100 Subject: [PATCH 4/5] fix(integrations): Reject lifecycle re-entry Prevent reused lifecycle objects from overwriting their active SCM interaction context. Refs SCM-304 --- .../source_code_management/metrics.py | 3 +++ .../test_commit_context.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/sentry/integrations/source_code_management/metrics.py b/src/sentry/integrations/source_code_management/metrics.py index b64fffaa5053..e2f734881aed 100644 --- a/src/sentry/integrations/source_code_management/metrics.py +++ b/src/sentry/integrations/source_code_management/metrics.py @@ -96,6 +96,9 @@ def __init__( 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__() diff --git a/tests/sentry/integrations/source_code_management/test_commit_context.py b/tests/sentry/integrations/source_code_management/test_commit_context.py index 526992de7969..ee9e215587a4 100644 --- a/tests/sentry/integrations/source_code_management/test_commit_context.py +++ b/tests/sentry/integrations/source_code_management/test_commit_context.py @@ -85,6 +85,22 @@ def test_scm_interaction_context_resets_after_exception(_mock_record: MagicMock) assert get_current_scm_interaction_type() is None +@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event") +def test_scm_interaction_context_rejects_reentry(_mock_record: MagicMock) -> None: + event = SCMIntegrationInteractionEvent( + interaction_type=SCMIntegrationInteractionType.SYNC_REPOS, + provider_key="github", + ) + lifecycle = event.capture() + + with lifecycle: + with pytest.raises(RuntimeError, match="cannot be re-entered"), lifecycle: + pass + assert get_current_scm_interaction_type() == SCMIntegrationInteractionType.SYNC_REPOS + + assert get_current_scm_interaction_type() is None + + class MockCommitContextIntegration(CommitContextIntegration): """Mock implementation for testing""" From 8fb60c82f2448de1e655e38e7dd7f37613d7c87d Mon Sep 17 00:00:00 2001 From: Giovanni M Guidini Date: Mon, 2 Nov 2026 09:37:48 +0100 Subject: [PATCH 5/5] ref(integrations): Return repository errors directly Refs SCM-304 --- .../api/endpoints/organization_integration_repos.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/sentry/integrations/api/endpoints/organization_integration_repos.py b/src/sentry/integrations/api/endpoints/organization_integration_repos.py index b4ff60a987d6..f633c4655e9d 100644 --- a/src/sentry/integrations/api/endpoints/organization_integration_repos.py +++ b/src/sentry/integrations/api/endpoints/organization_integration_repos.py @@ -111,7 +111,6 @@ def get( search = request.GET.get("search") accessible_only = request.GET.get("accessibleOnly", "false").lower() == "true" - user_facing_error = None with install.record_event( SCMIntegrationInteractionType.GET_REPOSITORY_CHOICES ).capture() as lifecycle: @@ -129,10 +128,7 @@ def get( ) except (IntegrationError, IdentityNotValid) as e: lifecycle.record_halt(e, create_issue=False) - user_facing_error = e - - if user_facing_error is not None: - return self.respond({"detail": str(user_facing_error)}, status=400) + return self.respond({"detail": str(e)}, status=400) installable_only = request.GET.get("installableOnly", "false").lower() == "true"