Skip to content

Commit fd07c08

Browse files
iamrajjoshievanh
authored andcommitted
♻️ ref(integrations): Add SLO for link_all_repos Celery Task (#81029)
`link_all_repos` is only for Github at the moment, but added observability on the task here. also updated the `SCMIntegrationInteractionEvent` to accept org_id
1 parent 7a7a997 commit fd07c08

File tree

3 files changed

+180
-53
lines changed

3 files changed

+180
-53
lines changed

src/sentry/integrations/github/tasks/link_all_repos.py

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
from sentry.constants import ObjectStatus
44
from sentry.integrations.services.integration import integration_service
5+
from sentry.integrations.source_code_management.metrics import (
6+
LinkAllReposHaltReason,
7+
SCMIntegrationInteractionEvent,
8+
SCMIntegrationInteractionType,
9+
)
510
from sentry.organizations.services.organization import organization_service
611
from sentry.plugins.providers.integration_repository import (
712
RepoExistsError,
@@ -35,52 +40,71 @@ def link_all_repos(
3540
integration_id: int,
3641
organization_id: int,
3742
):
38-
integration = integration_service.get_integration(
39-
integration_id=integration_id, status=ObjectStatus.ACTIVE
40-
)
41-
if not integration:
42-
logger.error(
43-
"%s.link_all_repos.integration_missing",
44-
integration_key,
45-
extra={"organization_id": organization_id},
46-
)
47-
metrics.incr("github.link_all_repos.error", tags={"type": "missing_integration"})
48-
return
49-
50-
rpc_org = organization_service.get(id=organization_id)
51-
if rpc_org is None:
52-
logger.error(
53-
"%s.link_all_repos.organization_missing",
54-
integration_key,
55-
extra={"organization_id": organization_id},
56-
)
57-
metrics.incr(
58-
f"{integration_key}.link_all_repos.error",
59-
tags={"type": "missing_organization"},
60-
)
61-
return
62-
63-
installation = integration.get_installation(organization_id=organization_id)
6443

65-
client = installation.get_client()
44+
with SCMIntegrationInteractionEvent(
45+
interaction_type=SCMIntegrationInteractionType.LINK_ALL_REPOS,
46+
provider_key=integration_key,
47+
).capture() as lifecycle:
48+
lifecycle.add_extra("organization_id", organization_id)
49+
integration = integration_service.get_integration(
50+
integration_id=integration_id, status=ObjectStatus.ACTIVE
51+
)
52+
if not integration:
53+
# TODO: Remove this logger in favor of context manager
54+
logger.error(
55+
"%s.link_all_repos.integration_missing",
56+
integration_key,
57+
extra={"organization_id": organization_id},
58+
)
59+
metrics.incr("github.link_all_repos.error", tags={"type": "missing_integration"})
60+
lifecycle.record_failure(str(LinkAllReposHaltReason.MISSING_INTEGRATION))
61+
return
6662

67-
try:
68-
repositories = client.get_repositories(fetch_max_pages=True)
69-
except ApiError as e:
70-
if installation.is_rate_limited_error(e):
63+
rpc_org = organization_service.get(id=organization_id)
64+
if rpc_org is None:
65+
logger.error(
66+
"%s.link_all_repos.organization_missing",
67+
integration_key,
68+
extra={"organization_id": organization_id},
69+
)
70+
metrics.incr(
71+
f"{integration_key}.link_all_repos.error",
72+
tags={"type": "missing_organization"},
73+
)
74+
lifecycle.record_failure(str(LinkAllReposHaltReason.MISSING_ORGANIZATION))
7175
return
7276

73-
metrics.incr(f"{integration_key}.link_all_repos.api_error")
74-
raise
77+
installation = integration.get_installation(organization_id=organization_id)
7578

76-
integration_repo_provider = get_integration_repository_provider(integration)
79+
client = installation.get_client()
7780

78-
for repo in repositories:
7981
try:
80-
config = get_repo_config(repo, integration_id)
81-
integration_repo_provider.create_repository(repo_config=config, organization=rpc_org)
82-
except KeyError:
83-
continue
84-
except RepoExistsError:
85-
metrics.incr("sentry.integration_repo_provider.repo_exists")
86-
continue
82+
repositories = client.get_repositories(fetch_max_pages=True)
83+
except ApiError as e:
84+
if installation.is_rate_limited_error(e):
85+
lifecycle.record_halt(str(LinkAllReposHaltReason.RATE_LIMITED))
86+
return
87+
88+
metrics.incr(f"{integration_key}.link_all_repos.api_error")
89+
raise
90+
91+
integration_repo_provider = get_integration_repository_provider(integration)
92+
93+
# If we successfully create any repositories, we'll set this to True
94+
success = False
95+
96+
for repo in repositories:
97+
try:
98+
config = get_repo_config(repo, integration_id)
99+
integration_repo_provider.create_repository(
100+
repo_config=config, organization=rpc_org
101+
)
102+
success = True
103+
except KeyError:
104+
continue
105+
except RepoExistsError:
106+
metrics.incr("sentry.integration_repo_provider.repo_exists")
107+
continue
108+
109+
if not success:
110+
lifecycle.record_halt(str(LinkAllReposHaltReason.REPOSITORY_NOT_CREATED))

src/sentry/integrations/source_code_management/metrics.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Mapping
2-
from enum import Enum
2+
from enum import Enum, StrEnum
33
from typing import Any
44

55
from attr import dataclass
@@ -29,6 +29,9 @@ class SCMIntegrationInteractionType(Enum):
2929
CREATE_COMMENT = "CREATE_COMMENT"
3030
UPDATE_COMMENT = "UPDATE_COMMENT"
3131

32+
# Tasks
33+
LINK_ALL_REPOS = "LINK_ALL_REPOS"
34+
3235
def __str__(self) -> str:
3336
return self.value.lower()
3437

@@ -60,3 +63,12 @@ def get_extras(self) -> Mapping[str, Any]:
6063
"organization_id": (self.organization.id if self.organization else None),
6164
"org_integration_id": (self.org_integration.id if self.org_integration else None),
6265
}
66+
67+
68+
class LinkAllReposHaltReason(StrEnum):
69+
"""Common reasons why a link all repos task may halt without success/failure."""
70+
71+
MISSING_INTEGRATION = "missing_integration"
72+
MISSING_ORGANIZATION = "missing_organization"
73+
RATE_LIMITED = "rate_limited"
74+
REPOSITORY_NOT_CREATED = "repository_not_created"

tests/sentry/integrations/github/tasks/test_link_all_repos.py

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,17 @@
77
from sentry.constants import ObjectStatus
88
from sentry.integrations.github.integration import GitHubIntegrationProvider
99
from sentry.integrations.github.tasks.link_all_repos import link_all_repos
10+
from sentry.integrations.source_code_management.metrics import LinkAllReposHaltReason
11+
from sentry.integrations.types import EventLifecycleOutcome
1012
from sentry.models.repository import Repository
1113
from sentry.shared_integrations.exceptions import ApiError
1214
from sentry.silo.base import SiloMode
1315
from sentry.testutils.cases import IntegrationTestCase
1416
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
17+
from tests.sentry.integrations.utils.test_assert_metrics import (
18+
assert_failure_metric,
19+
assert_halt_metric,
20+
)
1521

1622

1723
@control_silo_test
@@ -41,8 +47,9 @@ def _add_responses(self):
4147
},
4248
)
4349

50+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
4451
@patch("sentry.integrations.github.tasks.link_all_repos.metrics")
45-
def test_link_all_repos_inactive_integration(self, mock_metrics, _):
52+
def test_link_all_repos_inactive_integration(self, mock_metrics, mock_record, _):
4653
self.integration.update(status=ObjectStatus.DISABLED)
4754

4855
link_all_repos(
@@ -55,8 +62,14 @@ def test_link_all_repos_inactive_integration(self, mock_metrics, _):
5562
"github.link_all_repos.error", tags={"type": "missing_integration"}
5663
)
5764

65+
start, halt = mock_record.mock_calls
66+
assert start.args[0] == EventLifecycleOutcome.STARTED
67+
assert halt.args[0] == EventLifecycleOutcome.FAILURE
68+
assert_failure_metric(mock_record, LinkAllReposHaltReason.MISSING_INTEGRATION.value)
69+
5870
@responses.activate
59-
def test_link_all_repos(self, _):
71+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
72+
def test_link_all_repos(self, mock_record, _):
6073
self._add_responses()
6174

6275
link_all_repos(
@@ -76,8 +89,13 @@ def test_link_all_repos(self, _):
7689
assert repos[0].name == "getsentry/sentry"
7790
assert repos[1].name == "getsentry/snuba"
7891

92+
start, halt = mock_record.mock_calls
93+
assert start.args[0] == EventLifecycleOutcome.STARTED
94+
assert halt.args[0] == EventLifecycleOutcome.SUCCESS
95+
7996
@responses.activate
80-
def test_link_all_repos_api_response_keyerror(self, _):
97+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
98+
def test_link_all_repos_api_response_keyerror(self, mock_record, _):
8199

82100
responses.add(
83101
responses.GET,
@@ -112,8 +130,48 @@ def test_link_all_repos_api_response_keyerror(self, _):
112130

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

133+
start, halt = mock_record.mock_calls
134+
assert start.args[0] == EventLifecycleOutcome.STARTED
135+
assert halt.args[0] == EventLifecycleOutcome.SUCCESS
136+
137+
@responses.activate
138+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
139+
def test_link_all_repos_api_response_keyerror_single_repo(self, mock_record, _):
140+
141+
responses.add(
142+
responses.GET,
143+
self.base_url + "/installation/repositories?per_page=100",
144+
status=200,
145+
json={
146+
"total_count": 2,
147+
"repositories": [
148+
{
149+
"full_name": "getsentry/sentry",
150+
},
151+
],
152+
},
153+
)
154+
155+
link_all_repos(
156+
integration_key=self.key,
157+
integration_id=self.integration.id,
158+
organization_id=self.organization.id,
159+
)
160+
161+
with assume_test_silo_mode(SiloMode.REGION):
162+
repos = Repository.objects.all()
163+
assert len(repos) == 0
164+
165+
start, halt = mock_record.mock_calls
166+
167+
start, halt = mock_record.mock_calls
168+
assert start.args[0] == EventLifecycleOutcome.STARTED
169+
assert halt.args[0] == EventLifecycleOutcome.HALTED
170+
assert_halt_metric(mock_record, LinkAllReposHaltReason.REPOSITORY_NOT_CREATED.value)
171+
172+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
115173
@patch("sentry.integrations.github.tasks.link_all_repos.metrics")
116-
def test_link_all_repos_missing_integration(self, mock_metrics, _):
174+
def test_link_all_repos_missing_integration(self, mock_metrics, mock_record, _):
117175
link_all_repos(
118176
integration_key=self.key,
119177
integration_id=0,
@@ -123,8 +181,14 @@ def test_link_all_repos_missing_integration(self, mock_metrics, _):
123181
"github.link_all_repos.error", tags={"type": "missing_integration"}
124182
)
125183

184+
start, halt = mock_record.mock_calls
185+
assert start.args[0] == EventLifecycleOutcome.STARTED
186+
assert halt.args[0] == EventLifecycleOutcome.FAILURE
187+
assert_failure_metric(mock_record, LinkAllReposHaltReason.MISSING_INTEGRATION.value)
188+
189+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
126190
@patch("sentry.integrations.github.tasks.link_all_repos.metrics")
127-
def test_link_all_repos_missing_organization(self, mock_metrics, _):
191+
def test_link_all_repos_missing_organization(self, mock_metrics, mock_record, _):
128192
link_all_repos(
129193
integration_key=self.key,
130194
integration_id=self.integration.id,
@@ -134,9 +198,15 @@ def test_link_all_repos_missing_organization(self, mock_metrics, _):
134198
"github.link_all_repos.error", tags={"type": "missing_organization"}
135199
)
136200

201+
start, halt = mock_record.mock_calls
202+
assert start.args[0] == EventLifecycleOutcome.STARTED
203+
assert halt.args[0] == EventLifecycleOutcome.FAILURE
204+
assert_failure_metric(mock_record, LinkAllReposHaltReason.MISSING_ORGANIZATION.value)
205+
206+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
137207
@patch("sentry.integrations.github.tasks.link_all_repos.metrics")
138208
@responses.activate
139-
def test_link_all_repos_api_error(self, mock_metrics, _):
209+
def test_link_all_repos_api_error(self, mock_metrics, mock_record, _):
140210

141211
responses.add(
142212
responses.GET,
@@ -150,11 +220,16 @@ def test_link_all_repos_api_error(self, mock_metrics, _):
150220
integration_id=self.integration.id,
151221
organization_id=self.organization.id,
152222
)
153-
mock_metrics.incr.assert_called_with("github.link_all_repos.api_error")
223+
mock_metrics.incr.assert_called_with("github.link_all_repos.api_error")
154224

225+
start, halt = mock_record.mock_calls
226+
assert start.args[0] == EventLifecycleOutcome.STARTED
227+
assert halt.args[0] == EventLifecycleOutcome.FAILURE
228+
229+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
155230
@patch("sentry.integrations.github.integration.metrics")
156231
@responses.activate
157-
def test_link_all_repos_api_error_rate_limited(self, mock_metrics, _):
232+
def test_link_all_repos_api_error_rate_limited(self, mock_metrics, mock_record, _):
158233

159234
responses.add(
160235
responses.GET,
@@ -173,10 +248,16 @@ def test_link_all_repos_api_error_rate_limited(self, mock_metrics, _):
173248
)
174249
mock_metrics.incr.assert_called_with("github.link_all_repos.rate_limited_error")
175250

251+
start, halt = mock_record.mock_calls
252+
assert start.args[0] == EventLifecycleOutcome.STARTED
253+
assert halt.args[0] == EventLifecycleOutcome.HALTED
254+
assert_halt_metric(mock_record, LinkAllReposHaltReason.RATE_LIMITED.value)
255+
256+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
176257
@patch("sentry.models.Repository.objects.create")
177258
@patch("sentry.integrations.github.tasks.link_all_repos.metrics")
178259
@responses.activate
179-
def test_link_all_repos_repo_creation_error(self, mock_metrics, mock_repo, _):
260+
def test_link_all_repos_repo_creation_error(self, mock_metrics, mock_repo, mock_record, _):
180261
mock_repo.side_effect = IntegrityError
181262

182263
self._add_responses()
@@ -189,11 +270,17 @@ def test_link_all_repos_repo_creation_error(self, mock_metrics, mock_repo, _):
189270

190271
mock_metrics.incr.assert_called_with("sentry.integration_repo_provider.repo_exists")
191272

273+
start, halt = mock_record.mock_calls
274+
assert start.args[0] == EventLifecycleOutcome.STARTED
275+
assert halt.args[0] == EventLifecycleOutcome.HALTED
276+
assert_halt_metric(mock_record, LinkAllReposHaltReason.REPOSITORY_NOT_CREATED.value)
277+
278+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
192279
@patch("sentry.integrations.services.repository.repository_service.create_repository")
193280
@patch("sentry.plugins.providers.IntegrationRepositoryProvider.on_delete_repository")
194281
@responses.activate
195282
def test_link_all_repos_repo_creation_exception(
196-
self, mock_delete_repo, mock_create_repository, _
283+
self, mock_delete_repo, mock_create_repository, mock_record, _
197284
):
198285
mock_create_repository.return_value = None
199286
mock_delete_repo.side_effect = Exception
@@ -206,3 +293,7 @@ def test_link_all_repos_repo_creation_exception(
206293
integration_id=self.integration.id,
207294
organization_id=self.organization.id,
208295
)
296+
297+
start, halt = mock_record.mock_calls
298+
assert start.args[0] == EventLifecycleOutcome.STARTED
299+
assert halt.args[0] == EventLifecycleOutcome.FAILURE

0 commit comments

Comments
 (0)