Skip to content

Commit a071e2a

Browse files
authored
♻️ chore(slo): SLOs for CommitContextIntegration (#81225)
take 2, added ecosystem slo event lifecycles to the mixin.
1 parent 3888eac commit a071e2a

File tree

7 files changed

+406
-171
lines changed

7 files changed

+406
-171
lines changed

Diff for: src/sentry/integrations/source_code_management/commit_context.py

+213-161
Large diffs are not rendered by default.

Diff for: src/sentry/integrations/source_code_management/metrics.py

+39-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from collections.abc import Mapping
2-
from enum import Enum, StrEnum
2+
from enum import StrEnum
33
from typing import Any
44

55
from attr import dataclass
@@ -8,11 +8,14 @@
88
from sentry.integrations.models.organization_integration import OrganizationIntegration
99
from sentry.integrations.services.integration import RpcOrganizationIntegration
1010
from sentry.integrations.utils.metrics import IntegrationEventLifecycleMetric
11+
from sentry.models.commit import Commit
1112
from sentry.models.organization import Organization
13+
from sentry.models.project import Project
14+
from sentry.models.repository import Repository
1215
from sentry.organizations.services.organization import RpcOrganization
1316

1417

15-
class SCMIntegrationInteractionType(Enum):
18+
class SCMIntegrationInteractionType(StrEnum):
1619
"""
1720
SCM integration features
1821
"""
@@ -31,15 +34,14 @@ class SCMIntegrationInteractionType(Enum):
3134
GET = "GET"
3235

3336
# CommitContextIntegration
37+
GET_BLAME_FOR_FILES = "GET_BLAME_FOR_FILES"
3438
CREATE_COMMENT = "CREATE_COMMENT"
3539
UPDATE_COMMENT = "UPDATE_COMMENT"
40+
QUEUE_COMMENT_TASK = "QUEUE_COMMENT_TASK"
3641

3742
# Tasks
3843
LINK_ALL_REPOS = "LINK_ALL_REPOS"
3944

40-
def __str__(self) -> str:
41-
return self.value.lower()
42-
4345

4446
@dataclass
4547
class SCMIntegrationInteractionEvent(IntegrationEventLifecycleMetric):
@@ -70,6 +72,38 @@ def get_extras(self) -> Mapping[str, Any]:
7072
}
7173

7274

75+
@dataclass
76+
class CommitContextIntegrationInteractionEvent(SCMIntegrationInteractionEvent):
77+
"""
78+
An instance to be recorded of a CommitContextIntegration feature call.
79+
"""
80+
81+
project: Project | None = None
82+
commit: Commit | None = None
83+
repository: Repository | None = None
84+
pull_request_id: int | None = None
85+
86+
def get_extras(self) -> Mapping[str, Any]:
87+
parent_extras = super().get_extras()
88+
return {
89+
**parent_extras,
90+
"project_id": (self.project.id if self.project else None),
91+
"commit_id": (self.commit.id if self.commit else None),
92+
"repository_id": (self.repository.id if self.repository else None),
93+
"pull_request_id": self.pull_request_id,
94+
}
95+
96+
97+
class CommitContextHaltReason(StrEnum):
98+
"""Common reasons why a commit context integration may halt without success/failure."""
99+
100+
PR_BOT_DISABLED = "pr_bot_disabled"
101+
INCORRECT_REPO_CONFIG = "incorrect_repo_config"
102+
COMMIT_NOT_IN_DEFAULT_BRANCH = "commit_not_in_default_branch"
103+
MISSING_PR = "missing_pr"
104+
ALREADY_QUEUED = "already_queued"
105+
106+
73107
class LinkAllReposHaltReason(StrEnum):
74108
"""
75109
Common reasons why a link all repos task may halt without success/failure.

Diff for: src/sentry/integrations/utils/metrics.py

+4
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,10 @@ def add_extra(self, name: str, value: Any) -> None:
113113
"""
114114
self._extra[name] = value
115115

116+
def add_extras(self, extras: Mapping[str, int | str]) -> None:
117+
"""Add multiple values to logged "extra" data."""
118+
self._extra.update(extras)
119+
116120
def record_event(
117121
self, outcome: EventLifecycleOutcome, outcome_reason: BaseException | str | None = None
118122
) -> None:

Diff for: tests/sentry/integrations/source_code_management/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from unittest.mock import Mock, patch
2+
3+
from sentry.integrations.source_code_management.commit_context import (
4+
CommitContextIntegration,
5+
SourceLineInfo,
6+
)
7+
from sentry.integrations.types import EventLifecycleOutcome
8+
from sentry.models.repository import Repository
9+
from sentry.testutils.cases import TestCase
10+
from sentry.users.models.identity import Identity
11+
from tests.sentry.integrations.utils.test_assert_metrics import (
12+
assert_failure_metric,
13+
assert_halt_metric,
14+
)
15+
16+
17+
class MockCommitContextIntegration(CommitContextIntegration):
18+
"""Mock implementation for testing"""
19+
20+
integration_name = "mock_integration"
21+
22+
def __init__(self):
23+
self.client = Mock()
24+
25+
def get_client(self):
26+
return self.client
27+
28+
29+
class CommitContextIntegrationTest(TestCase):
30+
def setUp(self):
31+
super().setUp()
32+
self.integration = MockCommitContextIntegration()
33+
self.repo = Repository.objects.create(
34+
organization_id=self.organization.id,
35+
name="example/repo",
36+
)
37+
self.source_line = SourceLineInfo(
38+
lineno=10, path="src/file.py", ref="main", repo=self.repo, code_mapping=Mock()
39+
)
40+
41+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
42+
def test_get_blame_for_files_success(self, mock_record):
43+
"""Test successful blame retrieval records correct lifecycle events"""
44+
self.integration.client.get_blame_for_files.return_value = []
45+
46+
result = self.integration.get_blame_for_files([self.source_line], {})
47+
48+
assert result == []
49+
assert len(mock_record.mock_calls) == 2
50+
51+
start, success = mock_record.mock_calls
52+
assert start.args[0] == EventLifecycleOutcome.STARTED
53+
assert success.args[0] == EventLifecycleOutcome.SUCCESS
54+
55+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
56+
def test_get_blame_for_files_missing_identity(self, mock_record):
57+
"""Test missing identity records failure"""
58+
self.integration.get_client = Mock(side_effect=Identity.DoesNotExist())
59+
60+
result = self.integration.get_blame_for_files([self.source_line], {})
61+
62+
assert result == []
63+
assert len(mock_record.mock_calls) == 2
64+
65+
start, failure = mock_record.mock_calls
66+
assert start.args[0] == EventLifecycleOutcome.STARTED
67+
assert failure.args[0] == EventLifecycleOutcome.FAILURE
68+
assert_failure_metric(mock_record, Identity.DoesNotExist())
69+
70+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
71+
def test_get_blame_for_files_invalid_identity(self, mock_record):
72+
"""Test invalid identity records failure"""
73+
from sentry.auth.exceptions import IdentityNotValid
74+
75+
self.integration.client.get_blame_for_files = Mock(side_effect=IdentityNotValid())
76+
77+
result = self.integration.get_blame_for_files([self.source_line], {})
78+
79+
assert result == []
80+
assert len(mock_record.mock_calls) == 2
81+
82+
start, failure = mock_record.mock_calls
83+
assert start.args[0] == EventLifecycleOutcome.STARTED
84+
assert failure.args[0] == EventLifecycleOutcome.FAILURE
85+
assert_failure_metric(mock_record, IdentityNotValid())
86+
87+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
88+
def test_get_blame_for_files_rate_limited(self, mock_record):
89+
"""Test rate limited requests record halt"""
90+
from sentry.shared_integrations.exceptions import ApiRateLimitedError
91+
92+
self.integration.client.get_blame_for_files = Mock(
93+
side_effect=ApiRateLimitedError(text="Rate limited")
94+
)
95+
96+
result = self.integration.get_blame_for_files([self.source_line], {})
97+
98+
assert result == []
99+
assert len(mock_record.mock_calls) == 2
100+
101+
start, halt = mock_record.mock_calls
102+
assert start.args[0] == EventLifecycleOutcome.STARTED
103+
assert halt.args[0] == EventLifecycleOutcome.HALTED
104+
assert_halt_metric(mock_record, ApiRateLimitedError(text="Rate limited"))
105+
106+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
107+
def test_get_commit_context_all_frames(self, mock_record):
108+
"""Test get_commit_context_all_frames records correct lifecycle events"""
109+
self.integration.client.get_blame_for_files.return_value = []
110+
111+
result = self.integration.get_commit_context_all_frames([self.source_line], {})
112+
113+
assert result == []
114+
assert len(mock_record.mock_calls) == 2
115+
116+
start, success = mock_record.mock_calls
117+
assert start.args[0] == EventLifecycleOutcome.STARTED
118+
assert success.args[0] == EventLifecycleOutcome.SUCCESS

Diff for: tests/sentry/integrations/utils/test_assert_metrics.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,20 @@
66

77

88
def assert_halt_metric(mock_record, error_msg):
9-
(event_failures,) = (
9+
(event_halts,) = (
1010
call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.HALTED
1111
)
12-
assert event_failures.args[1] == error_msg
12+
if isinstance(error_msg, Exception):
13+
assert isinstance(event_halts.args[1], type(error_msg))
14+
else:
15+
assert event_halts.args[1] == error_msg
1316

1417

1518
def assert_failure_metric(mock_record, error_msg):
1619
(event_failures,) = (
1720
call for call in mock_record.mock_calls if call.args[0] == EventLifecycleOutcome.FAILURE
1821
)
19-
assert event_failures.args[1] == error_msg
22+
if isinstance(error_msg, Exception):
23+
assert isinstance(event_failures.args[1], type(error_msg))
24+
else:
25+
assert event_failures.args[1] == error_msg

Diff for: tests/sentry/tasks/test_commit_context.py

+23-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
FileBlameInfo,
1818
SourceLineInfo,
1919
)
20+
from sentry.integrations.source_code_management.metrics import CommitContextHaltReason
21+
from sentry.integrations.types import EventLifecycleOutcome
2022
from sentry.models.commit import Commit
2123
from sentry.models.commitauthor import CommitAuthor
2224
from sentry.models.groupowner import GroupOwner, GroupOwnerType
@@ -36,6 +38,7 @@
3638
from sentry.testutils.silo import assume_test_silo_mode
3739
from sentry.testutils.skips import requires_snuba
3840
from sentry.utils.committers import get_frame_paths
41+
from tests.sentry.integrations.utils.test_assert_metrics import assert_halt_metric
3942

4043
pytestmark = [requires_snuba]
4144

@@ -1183,9 +1186,12 @@ def test_gh_comment_no_repo(self, mock_comment_workflow, mock_get_commit_context
11831186
assert not mock_comment_workflow.called
11841187
assert len(PullRequestCommit.objects.all()) == 0
11851188

1189+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
11861190
@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1")
11871191
@responses.activate
1188-
def test_gh_comment_debounces(self, get_jwt, mock_comment_workflow, mock_get_commit_context):
1192+
def test_gh_comment_debounces(
1193+
self, get_jwt, mock_record, mock_comment_workflow, mock_get_commit_context
1194+
):
11891195
mock_get_commit_context.return_value = [self.blame]
11901196
self.add_responses()
11911197
assert not GroupOwner.objects.filter(group=self.event.group).exists()
@@ -1223,10 +1229,18 @@ def test_gh_comment_debounces(self, get_jwt, mock_comment_workflow, mock_get_com
12231229
)
12241230
assert mock_comment_workflow.call_count == 1
12251231

1232+
start_1, success_1, start_2, halt_2 = mock_record.mock_calls
1233+
assert start_1.args[0] == EventLifecycleOutcome.STARTED
1234+
assert success_1.args[0] == EventLifecycleOutcome.SUCCESS
1235+
assert start_2.args[0] == EventLifecycleOutcome.STARTED
1236+
assert halt_2.args[0] == EventLifecycleOutcome.HALTED
1237+
assert_halt_metric(mock_record, CommitContextHaltReason.ALREADY_QUEUED)
1238+
1239+
@patch("sentry.integrations.utils.metrics.EventLifecycle.record_event")
12261240
@patch("sentry.integrations.github.client.get_jwt", return_value="jwt_token_1")
12271241
@responses.activate
12281242
def test_gh_comment_multiple_comments(
1229-
self, get_jwt, mock_comment_workflow, mock_get_commit_context
1243+
self, get_jwt, mock_record, mock_comment_workflow, mock_get_commit_context
12301244
):
12311245
self.add_responses()
12321246

@@ -1274,3 +1288,10 @@ def test_gh_comment_multiple_comments(
12741288
group_id=self.event.group_id,
12751289
)
12761290
assert mock_comment_workflow.call_count == 1
1291+
1292+
start_1, success_1, start_2, halt_2 = mock_record.mock_calls
1293+
assert start_1.args[0] == EventLifecycleOutcome.STARTED
1294+
assert success_1.args[0] == EventLifecycleOutcome.SUCCESS
1295+
assert start_2.args[0] == EventLifecycleOutcome.STARTED
1296+
assert halt_2.args[0] == EventLifecycleOutcome.HALTED
1297+
assert_halt_metric(mock_record, CommitContextHaltReason.ALREADY_QUEUED)

0 commit comments

Comments
 (0)