Skip to content

Commit 5e2c24b

Browse files
ceorourkegetsantry[bot]
authored andcommitted
ref(rules): Add changes to rule edit confirmation notification (#66847)
Follow up to #66285 to add details about what changed happened when a rule was edited to the notification. <img width="748" alt="Screenshot 2024-03-12 at 4 40 24 PM" src="https://github.com/getsentry/sentry/assets/29959063/e0d7c5ca-35e5-4a66-bc67-e53c8d27dcce"> Note that I've made a couple follow up tickets to address poor rendering of issue type enums and frequency: getsentry/team-core-product-foundations#158 and getsentry/team-core-product-foundations#157 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent b849e97 commit 5e2c24b

File tree

9 files changed

+169
-16
lines changed

9 files changed

+169
-16
lines changed

src/sentry/api/endpoints/project_rule_details.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from sentry.models.team import Team
4343
from sentry.models.user import User
4444
from sentry.rules.actions import trigger_sentry_app_action_creators_for_issues
45+
from sentry.rules.actions.utils import get_changed_data, get_updated_rule_data
4546
from sentry.signals import alert_rule_edited
4647
from sentry.tasks.integrations.slack import find_channel_id_for_rule
4748

@@ -228,6 +229,13 @@ def put(self, request: Request, project, rule) -> Response:
228229
- Filters - help control noise by triggering an alert only if the issue matches the specified criteria.
229230
- Actions - specify what should happen when the trigger conditions are met and the filters match.
230231
"""
232+
rule_data_before = dict(rule.data)
233+
if rule.environment_id:
234+
rule_data_before["environment_id"] = rule.environment_id
235+
if rule.owner:
236+
rule_data_before["owner"] = rule.owner
237+
rule_data_before["label"] = rule.label
238+
231239
serializer = DrfRuleSerializer(
232240
context={"project": project, "organization": project.organization},
233241
data=request.data,
@@ -367,7 +375,9 @@ def put(self, request: Request, project, rule) -> Response:
367375
if features.has(
368376
"organizations:rule-create-edit-confirm-notification", project.organization
369377
):
370-
send_confirmation_notification(rule=rule, new=False)
378+
new_rule_data = get_updated_rule_data(rule)
379+
changed_data = get_changed_data(rule, new_rule_data, rule_data_before)
380+
send_confirmation_notification(rule=rule, new=False, changed=changed_data)
371381
return Response(serialize(updated_rule, request.user))
372382

373383
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

src/sentry/api/endpoints/project_rules.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,14 @@
3838
from sentry.utils.safe import safe_execute
3939

4040

41-
def send_confirmation_notification(rule: Rule, new: bool):
41+
def send_confirmation_notification(rule: Rule, new: bool, changed: dict | None = None):
4242
for action in rule.data.get("actions", ()):
4343
action_inst = instantiate_action(rule, action)
4444
safe_execute(
4545
action_inst.send_confirmation_notification,
4646
rule=rule,
4747
new=new,
48+
changed=changed,
4849
)
4950

5051

src/sentry/api/serializers/models/rule.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from sentry.services.hybrid_cloud.user.service import user_service
1515

1616

17-
def _generate_rule_label(project, rule, data):
17+
def generate_rule_label(project, rule, data):
1818
from sentry.rules import rules
1919

2020
rule_cls = rules.get(data["id"])
@@ -202,7 +202,7 @@ def get_attrs(self, item_list, user, **kwargs):
202202
def serialize(self, obj, attrs, user, **kwargs) -> RuleSerializerResponse:
203203
environment = attrs["environment"]
204204
all_conditions = [
205-
dict(list(o.items()) + [("name", _generate_rule_label(obj.project, obj, o))])
205+
dict(list(o.items()) + [("name", generate_rule_label(obj.project, obj, o))])
206206
for o in obj.data.get("conditions", [])
207207
]
208208

@@ -212,7 +212,7 @@ def serialize(self, obj, attrs, user, **kwargs) -> RuleSerializerResponse:
212212
actions.append(
213213
dict(
214214
list(action.items())
215-
+ [("name", _generate_rule_label(obj.project, obj, action))]
215+
+ [("name", generate_rule_label(obj.project, obj, action))]
216216
)
217217
)
218218
except serializers.ValidationError:

src/sentry/integrations/slack/actions/notification.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,14 +145,16 @@ def send_notification(event: GroupEvent, futures: Sequence[RuleFuture]) -> None:
145145
metrics.incr("notifications.sent", instance="slack.notification", skip_internal=False)
146146
yield self.future(send_notification, key=key)
147147

148-
def send_confirmation_notification(self, rule: Rule, new: bool):
148+
def send_confirmation_notification(
149+
self, rule: Rule, new: bool, changed: dict[str, Any] | None = None
150+
):
149151
integration = self.get_integration()
150152
if not integration:
151153
# Integration removed, rule still active.
152154
return
153155

154156
channel = self.get_option("channel_id")
155-
blocks = SlackRuleSaveEditMessageBuilder(rule=rule, new=new).build()
157+
blocks = SlackRuleSaveEditMessageBuilder(rule=rule, new=new, changed=changed).build()
156158
payload = {
157159
"text": blocks.get("text"),
158160
"blocks": json.dumps(blocks.get("blocks")),

src/sentry/integrations/slack/message_builder/notifications/rule_save_edit.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ def __init__(
1313
self,
1414
rule: Rule,
1515
new: bool,
16+
changed: dict | None = None,
1617
) -> None:
1718
super().__init__()
1819
self.rule = rule
1920
self.new = new
21+
self.changed = changed
2022

2123
def linkify_rule(self):
2224
org_slug = self.rule.project.organization.slug
@@ -40,13 +42,21 @@ def build(self) -> SlackBlock:
4042
rule_url = self.linkify_rule()
4143
project = self.rule.project.slug
4244
if self.new:
43-
rule_text = f"{rule_url} was created in the *{project}* project and will send notifications here."
45+
rule_text = f"Alert rule {rule_url} was created in the *{project}* project and will send notifications here."
4446
else:
45-
rule_text = f"{rule_url} in the *{project}* project was updated."
47+
rule_text = f"Alert rule {rule_url} in the *{project}* project was updated."
48+
# TODO potentially use old name if it's changed?
4649

47-
# TODO add short summary of the trigger & filter conditions
4850
blocks.append(self.get_markdown_block(rule_text))
4951

52+
if not self.new and self.changed:
53+
changes_text = "*Changes*\n"
54+
for changes in self.changed.values():
55+
for change in changes:
56+
changes_text += f"• {change}\n"
57+
58+
blocks.append(self.get_markdown_block(changes_text))
59+
5060
settings_link = self.get_settings_url()
5161
blocks.append(self.get_context_block(settings_link))
5262

src/sentry/rules/actions/base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import abc
44
import logging
55
from collections.abc import Generator
6+
from typing import Any
67

78
from sentry.eventstore.models import GroupEvent
89
from sentry.models.rule import Rule
@@ -54,7 +55,7 @@ def after(
5455
>>> print(future)
5556
"""
5657

57-
def send_confirmation_notification(self, rule: Rule, new: bool):
58+
def send_confirmation_notification(self, rule: Rule, new: bool, changed: dict[str, Any]):
5859
"""
5960
Send a notification confirming that a rule was created or edited
6061
"""

src/sentry/rules/actions/utils.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
from collections import defaultdict
2+
from typing import Any, DefaultDict
3+
4+
from sentry.api.serializers.models.rule import generate_rule_label
5+
from sentry.models.environment import Environment
6+
from sentry.models.rule import Rule
7+
8+
9+
def get_updated_rule_data(rule: Rule) -> dict[str, Any]:
10+
rule_data = dict(rule.data)
11+
if rule.environment_id:
12+
rule_data["environment_id"] = rule.environment_id
13+
if rule.owner:
14+
rule_data["owner"] = rule.owner
15+
rule_data["label"] = rule.label
16+
return rule_data
17+
18+
19+
def check_value_changed(
20+
present_state: dict[str, Any], prior_state: dict[str, Any], key: str, word: str
21+
) -> str | None:
22+
if present_state.get(key) != prior_state.get(key):
23+
old_value = prior_state.get(key)
24+
new_value = present_state.get(key)
25+
return f"Changed {word} from *{old_value}* to *{new_value}*"
26+
return None
27+
28+
29+
def generate_diff_labels(
30+
present_state: dict[str, Any],
31+
prior_state: dict[str, Any],
32+
rule: Rule,
33+
changed_data: DefaultDict[str, list[str]],
34+
key: str,
35+
statement: str,
36+
) -> DefaultDict[str, list[str]]:
37+
for data in prior_state.get(key, []):
38+
if data not in present_state.get(key, []):
39+
label = generate_rule_label(rule.project, rule, data)
40+
changed_data[data["id"]].append(statement.format(label))
41+
42+
return changed_data
43+
44+
45+
def get_changed_data(
46+
rule: Rule, rule_data: dict[str, Any], rule_data_before: dict[str, Any]
47+
) -> dict[str, Any]:
48+
"""
49+
Generate a list per type of issue alert rule data of what changes occurred on edit.
50+
"""
51+
changed_data: DefaultDict[str, list[str]] = defaultdict(list)
52+
changed_data = generate_diff_labels(
53+
rule_data_before, rule_data, rule, changed_data, "conditions", "Added condition '{}'"
54+
)
55+
changed_data = generate_diff_labels(
56+
rule_data, rule_data_before, rule, changed_data, "conditions", "Removed condition '{}'"
57+
)
58+
changed_data = generate_diff_labels(
59+
rule_data_before, rule_data, rule, changed_data, "actions", "Added action '{}'"
60+
)
61+
changed_data = generate_diff_labels(
62+
rule_data, rule_data_before, rule, changed_data, "actions", "Removed action '{}'"
63+
)
64+
65+
frequency_text = check_value_changed(rule_data, rule_data_before, "frequency", "frequency")
66+
if frequency_text:
67+
changed_data["changed_frequency"].append(frequency_text)
68+
69+
if rule_data.get("environment_id") and not rule_data_before.get("environment_id"):
70+
environment = None
71+
try:
72+
environment = Environment.objects.get(id=rule_data.get("environment_id"))
73+
except Environment.DoesNotExist:
74+
pass
75+
76+
if environment:
77+
changed_data["environment"].append(f"Added *{environment.name}* environment")
78+
79+
if rule_data_before.get("environment_id") and not rule_data.get("environment_id"):
80+
environment = None
81+
try:
82+
environment = Environment.objects.get(id=rule_data.get("environment_id"))
83+
except Environment.DoesNotExist:
84+
pass
85+
86+
if environment:
87+
changed_data["environment"].append(f"Removed *{environment.name}* environment")
88+
89+
label_text = check_value_changed(rule_data, rule_data_before, "label", "rule name")
90+
if label_text:
91+
changed_data["changed_label"].append(label_text)
92+
93+
action_match_text = check_value_changed(rule_data, rule_data_before, "action_match", "trigger")
94+
if action_match_text:
95+
changed_data["action_match"].append(action_match_text)
96+
97+
filter_match_text = check_value_changed(rule_data, rule_data_before, "filter_match", "filter")
98+
if filter_match_text:
99+
changed_data["filter_match"].append(filter_match_text)
100+
101+
if rule_data_before.get("owner") != rule_data.get("owner"):
102+
old_owner = rule_data_before.get("owner")
103+
old_actor = "Unassigned"
104+
if old_owner:
105+
old_actor = old_owner.resolve()
106+
107+
new_owner = rule_data.get("owner")
108+
new_actor = "Unassigned"
109+
if new_owner:
110+
new_actor = new_owner.resolve()
111+
owner_changed_text = f"Changed owner from *{old_actor}* to *{new_actor}*"
112+
changed_data["owner"].append(owner_changed_text)
113+
114+
return changed_data

tests/sentry/api/endpoints/test_project_rule_details.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1013,7 +1013,7 @@ def test_slack_confirmation_notification_contents(self):
10131013
"channel": "#old_channel_name",
10141014
}
10151015
]
1016-
self.rule.update(data={"conditions": conditions, "actions": actions})
1016+
self.rule.update(data={"conditions": conditions, "actions": actions}, label="my rule")
10171017

10181018
actions[0]["channel"] = "#new_channel_name"
10191019
actions[0]["channel_id"] = "new_channel_id"
@@ -1054,13 +1054,18 @@ def test_slack_confirmation_notification_contents(self):
10541054
content_type="application/json",
10551055
body=json.dumps(payload),
10561056
)
1057+
staging_env = self.create_environment(
1058+
self.project, name="staging", organization=self.organization
1059+
)
10571060
payload = {
1058-
"name": "#new_channel_name",
1061+
"name": "new rule",
10591062
"actionMatch": "any",
10601063
"filterMatch": "any",
10611064
"actions": actions,
10621065
"conditions": conditions,
10631066
"frequency": 30,
1067+
"environment": staging_env.name,
1068+
"owner": get_actor_for_user(self.user).get_actor_identifier(),
10641069
}
10651070
response = self.get_success_response(
10661071
self.organization.slug, self.project.slug, self.rule.id, status_code=200, **payload
@@ -1069,12 +1074,22 @@ def test_slack_confirmation_notification_contents(self):
10691074
rule_label = response.data["name"]
10701075
assert response.data["actions"][0]["channel_id"] == "new_channel_id"
10711076
data = parse_qs(responses.calls[1].request.body)
1072-
message = f"<http://testserver/organizations/{self.organization.slug}/alerts/rules/{self.project.slug}/{rule_id}/details/|*{rule_label}*> in the *{self.project.slug}* project was updated."
1077+
message = f"Alert rule <http://testserver/organizations/{self.organization.slug}/alerts/rules/{self.project.slug}/{rule_id}/details/|*{rule_label}*> in the *{self.project.slug}* project was updated."
10731078
assert data["text"][0] == message
10741079
rendered_blocks = json.loads(data["blocks"][0])
10751080
assert rendered_blocks[0]["text"]["text"] == message
1081+
changes = "*Changes*\n"
1082+
changes += "• Added action 'Send a notification to the Awesome Team Slack workspace to new_channel_name (optionally, an ID: new_channel_id) and show tags [] in notification'\n"
1083+
changes += "• Removed action 'Send a notification to the Awesome Team Slack workspace to #old_channel_name (optionally, an ID: old_channel_id) and show tags [] in notification'\n"
1084+
changes += "• Changed frequency from *None* to *30*\n"
1085+
changes += f"• Added *{staging_env.name}* environment\n"
1086+
changes += "• Changed rule name from *my rule* to *new rule*\n"
1087+
changes += "• Changed trigger from *None* to *any*\n"
1088+
changes += "• Changed filter from *None* to *any*\n"
1089+
changes += f"• Changed owner from *Unassigned* to *{self.user.email}*\n"
1090+
assert rendered_blocks[1]["text"]["text"] == changes
10761091
assert (
1077-
rendered_blocks[1]["elements"][0]["text"]
1092+
rendered_blocks[2]["elements"][0]["text"]
10781093
== "<http://testserver/settings/account/notifications/alerts/|*Notification Settings*>"
10791094
)
10801095

tests/sentry/api/endpoints/test_project_rules.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ def test_slack_confirmation_notification_contents(self):
423423
rule_label = response.data["name"]
424424
assert response.data["actions"][0]["channel_id"] == self.channel_id
425425
data = parse_qs(responses.calls[1].request.body)
426-
message = f"<http://testserver/organizations/{self.organization.slug}/alerts/rules/{self.project.slug}/{rule_id}/details/|*{rule_label}*> was created in the *{self.project.slug}* project and will send notifications here."
426+
message = f"Alert rule <http://testserver/organizations/{self.organization.slug}/alerts/rules/{self.project.slug}/{rule_id}/details/|*{rule_label}*> was created in the *{self.project.slug}* project and will send notifications here."
427427
assert data["text"][0] == message
428428
rendered_blocks = json.loads(data["blocks"][0])
429429
assert rendered_blocks[0]["text"]["text"] == message

0 commit comments

Comments
 (0)