Skip to content

Commit f0f1974

Browse files
authored
fix(feedback): fix typing for shims from v2 to v1 (#89949)
Follow-up on #89685. Fixes [SENTRY-3SEX](https://sentry.sentry.io/issues/6551468609/events/9a88f59af64d4f86bddf42060cdff0d9/) Fixes [SENTRY-3SEZ](https://sentry.sentry.io/issues/6551532041/events/bd020dada1a74f1ba96faa07c33df30c/) Fixes [SENTRY-3SEY](https://sentry.sentry.io/issues/6551484362/events/42136852328a4acdb6f21922bd6425af/) Only the message/comments field is required, so we need to handle missing name or email. Also event timestamp is a ISO str. Also adds better typing to `save_userreport` and cleans up the event formatting code in `create_feedback` a bit. Functionality is the same, except now we'll always include the `user.email` context/tag, defaulting to empty str `""`
1 parent 13efe59 commit f0f1974

File tree

7 files changed

+102
-37
lines changed

7 files changed

+102
-37
lines changed

src/sentry/feedback/lib/types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import NotRequired, TypedDict
2+
3+
4+
class UserReportDict(TypedDict):
5+
"""Use for weak type checking of user report data. Keys correspond to fields of the UserReport model."""
6+
7+
event_id: str
8+
comments: str
9+
# required for the model, but functions usually infer this from an explicit Project argument.
10+
project_id: NotRequired[int]
11+
name: NotRequired[str]
12+
email: NotRequired[str]
13+
environment_id: NotRequired[int] # defaults to "production".
14+
group_id: NotRequired[int]
15+
level: NotRequired[str] # defaults to "info".

src/sentry/feedback/usecases/create_feedback.py

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
import random
55
from datetime import UTC, datetime
66
from enum import Enum
7-
from typing import Any, TypedDict
7+
from typing import Any
88
from uuid import uuid4
99

1010
import jsonschema
1111

1212
from sentry import features, options
1313
from sentry.constants import DataCategory
1414
from sentry.eventstore.models import Event, GroupEvent
15+
from sentry.feedback.lib.types import UserReportDict
1516
from sentry.feedback.usecases.spam_detection import is_spam, spam_detection_enabled
1617
from sentry.issues.grouptype import FeedbackGroup
1718
from sentry.issues.issue_occurrence import IssueEvidence, IssueOccurrence
@@ -100,8 +101,10 @@ def fix_for_issue_platform(event_data: dict[str, Any]) -> dict[str, Any]:
100101
"""
101102
The issue platform has slightly different requirements than ingest for event schema,
102103
so we need to massage the data a bit.
104+
* event["timestamp"] is converted to a UTC ISO string.
103105
* event["tags"] is coerced to a dict.
104-
* If event["user"]["email"] is missing we try to set using the feedback context.
106+
* If user or replay context is missing we try to set it using the feedback context.
107+
* level defaults to "info" and environment defaults to "production".
105108
106109
Returns:
107110
A dict[str, Any] conforming to sentry.issues.json_schemas.EVENT_PAYLOAD_SCHEMA.
@@ -135,22 +138,19 @@ def fix_for_issue_platform(event_data: dict[str, Any]) -> dict[str, Any]:
135138
ret_event["request"] = event_data.get("request", {})
136139

137140
ret_event["user"] = event_data.get("user", {})
141+
if "name" in ret_event["user"]:
142+
del ret_event["user"]["name"]
138143

139-
if event_data.get("dist") is not None:
140-
del event_data["dist"]
141-
if event_data.get("user", {}).get("name") is not None:
142-
del event_data["user"]["name"]
143-
if event_data.get("user", {}).get("isStaff") is not None:
144-
del event_data["user"]["isStaff"]
144+
if "isStaff" in ret_event["user"]:
145+
del ret_event["user"]["isStaff"]
145146

146-
if event_data.get("user", {}).get("id") is not None:
147-
event_data["user"]["id"] = str(event_data["user"]["id"])
147+
if "id" in ret_event["user"]:
148+
ret_event["user"]["id"] = str(ret_event["user"]["id"])
148149

149150
# If no user email was provided specify the contact-email as the user-email.
150151
feedback_obj = event_data.get("contexts", {}).get("feedback", {})
151-
contact_email = feedback_obj.get("contact_email")
152-
if not ret_event["user"].get("email", ""):
153-
ret_event["user"]["email"] = contact_email
152+
if "email" not in ret_event["user"]:
153+
ret_event["user"]["email"] = feedback_obj.get("contact_email", "")
154154

155155
# Force `tags` to be a dict if it's initially a list,
156156
# since we can't guarantee its type here.
@@ -451,16 +451,8 @@ def auto_ignore_spam_feedbacks(project, issue_fingerprint):
451451
###########
452452

453453

454-
class UserReportShimDict(TypedDict):
455-
name: str
456-
email: str
457-
comments: str
458-
event_id: str
459-
level: str
460-
461-
462454
def shim_to_feedback(
463-
report: UserReportShimDict,
455+
report: UserReportDict,
464456
event: Event | GroupEvent,
465457
project: Project,
466458
source: FeedbackCreationSource,
@@ -491,7 +483,7 @@ def shim_to_feedback(
491483
"contexts": {
492484
"feedback": {
493485
"name": report.get("name", ""),
494-
"contact_email": report["email"],
486+
"contact_email": report.get("email", ""),
495487
"message": report["comments"],
496488
},
497489
},

src/sentry/feedback/usecases/save_feedback_event.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,15 @@ def save_feedback_event(event_data: Mapping[str, Any], project_id: int):
3232
# Shim to UserReport
3333
feedback_context = fixed_event_data["contexts"]["feedback"]
3434
associated_event_id = feedback_context.get("associated_event_id")
35+
3536
if associated_event_id:
3637
project = Project.objects.get_from_cache(id=project_id)
38+
timestamp = fixed_event_data["timestamp"]
39+
start_time = (
40+
datetime.fromtimestamp(timestamp, tz=UTC)
41+
if isinstance(timestamp, float)
42+
else datetime.fromisoformat(timestamp)
43+
)
3744
save_userreport(
3845
project,
3946
{
@@ -42,12 +49,12 @@ def save_feedback_event(event_data: Mapping[str, Any], project_id: int):
4249
# XXX(aliu): including environment ensures the update_user_reports task
4350
# will not shim the report back to feedback.
4451
"environment_id": fixed_event_data.get("environment", "production"),
45-
"name": feedback_context["name"],
46-
"email": feedback_context["contact_email"],
52+
"name": feedback_context.get("name", ""),
53+
"email": feedback_context.get("contact_email", ""),
4754
"comments": feedback_context["message"],
4855
},
4956
FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE,
50-
start_time=datetime.fromtimestamp(fixed_event_data["timestamp"], UTC),
57+
start_time=start_time,
5158
)
5259
metrics.incr("feedback.shim_to_userreport.success")
5360

src/sentry/ingest/userreport.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from sentry import eventstore, options
1111
from sentry.constants import DataCategory
1212
from sentry.eventstore.models import Event, GroupEvent
13+
from sentry.feedback.lib.types import UserReportDict
1314
from sentry.feedback.usecases.create_feedback import (
1415
UNREAL_FEEDBACK_UNATTENDED_MESSAGE,
1516
FeedbackCreationSource,
@@ -34,7 +35,7 @@ class Conflict(Exception):
3435

3536
def save_userreport(
3637
project: Project,
37-
report,
38+
report: UserReportDict,
3839
source: FeedbackCreationSource,
3940
start_time: datetime | None = None,
4041
) -> UserReport | None:
@@ -100,7 +101,8 @@ def save_userreport(
100101
raise Conflict("Feedback for this event cannot be modified.")
101102

102103
report["environment_id"] = event.get_environment().id
103-
report["group_id"] = event.group_id
104+
if event.group_id:
105+
report["group_id"] = event.group_id
104106

105107
# Save the report.
106108
try:
@@ -126,7 +128,7 @@ def save_userreport(
126128

127129
existing_report.update(
128130
name=report.get("name", ""),
129-
email=report["email"],
131+
email=report.get("email", ""),
130132
comments=report["comments"],
131133
)
132134
report_instance = existing_report

tests/sentry/api/endpoints/test_organization_user_reports.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import UTC, datetime, timedelta
22
from unittest.mock import patch
33

4+
from sentry.feedback.lib.types import UserReportDict
45
from sentry.feedback.usecases.create_feedback import FeedbackCreationSource
56
from sentry.ingest.userreport import save_userreport
67
from sentry.models.group import GroupStatus
@@ -132,11 +133,12 @@ def test_with_event_user(self):
132133
)
133134

134135
# Simulate how ingest saves reports to get event_user connections
135-
report_data = {
136+
report_data: UserReportDict = {
136137
"event_id": event.event_id,
137138
"name": "",
138139
"email": "",
139140
"comments": "It broke",
141+
"project_id": self.project_1.id,
140142
}
141143
save_userreport(
142144
self.project_1, report_data, FeedbackCreationSource.USER_REPORT_DJANGO_ENDPOINT

tests/sentry/feedback/usecases/test_save_feedback_event.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,19 @@ def test_save_feedback_event_no_associated_error(
3737

3838

3939
@django_db_all
40+
@pytest.mark.parametrize(
41+
"timestamp_format",
42+
["number", "iso"],
43+
)
4044
def test_save_feedback_event_with_associated_error(
41-
default_project, mock_create_feedback_issue, mock_save_userreport
45+
default_project, mock_create_feedback_issue, mock_save_userreport, timestamp_format
4246
):
4347
event_data = mock_feedback_event(default_project.id)
4448
event_data["contexts"]["feedback"]["associated_event_id"] = "abcd" * 8
49+
start_time = datetime.now(UTC)
50+
event_data["timestamp"] = (
51+
start_time.timestamp() if timestamp_format == "number" else start_time.isoformat()
52+
)
4553
mock_create_feedback_issue.return_value = event_data
4654

4755
save_feedback_event(event_data, default_project.id)
@@ -62,5 +70,43 @@ def test_save_feedback_event_with_associated_error(
6270
"comments": feedback_context["message"],
6371
},
6472
FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE,
65-
start_time=datetime.fromtimestamp(event_data["timestamp"], UTC),
73+
start_time=start_time,
74+
)
75+
76+
77+
@django_db_all
78+
def test_save_feedback_event_with_associated_error_and_missing_fields(
79+
default_project, mock_create_feedback_issue, mock_save_userreport
80+
):
81+
event_data = mock_feedback_event(default_project.id)
82+
event_data["contexts"]["feedback"]["associated_event_id"] = "abcd" * 8
83+
start_time = datetime.now(UTC)
84+
event_data["timestamp"] = start_time.isoformat()
85+
86+
# Remove name, email, environment
87+
del event_data["contexts"]["feedback"]["name"]
88+
del event_data["contexts"]["feedback"]["contact_email"]
89+
del event_data["environment"]
90+
91+
mock_create_feedback_issue.return_value = event_data
92+
93+
save_feedback_event(event_data, default_project.id)
94+
95+
mock_create_feedback_issue.assert_called_once_with(
96+
event_data, default_project.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
97+
)
98+
99+
feedback_context = event_data["contexts"]["feedback"]
100+
mock_save_userreport.assert_called_once_with(
101+
default_project,
102+
{
103+
"event_id": "abcd" * 8,
104+
"project_id": default_project.id,
105+
"environment_id": "production",
106+
"name": "",
107+
"email": "",
108+
"comments": feedback_context["message"],
109+
},
110+
FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE,
111+
start_time=start_time,
66112
)

tests/sentry/ingest/test_userreport.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from django.utils import timezone
44

5+
from sentry.feedback.lib.types import UserReportDict
56
from sentry.feedback.usecases.create_feedback import (
67
UNREAL_FEEDBACK_UNATTENDED_MESSAGE,
78
FeedbackCreationSource,
@@ -73,7 +74,7 @@ def test_save_user_report_returns_instance(default_project, monkeypatch):
7374
)
7475

7576
# Test data
76-
report = {
77+
report: UserReportDict = {
7778
"event_id": "123456",
7879
"name": "Test User",
7980
"email": "test@example.com",
@@ -91,7 +92,7 @@ def test_save_user_report_returns_instance(default_project, monkeypatch):
9192
@django_db_all
9293
def test_save_user_report_denylist(default_project, monkeypatch):
9394
monkeypatch.setattr("sentry.ingest.userreport.is_in_feedback_denylist", lambda org: True)
94-
report = {
95+
report: UserReportDict = {
9596
"event_id": "123456",
9697
"name": "Test User",
9798
"email": "test@example.com",
@@ -116,7 +117,7 @@ def test_save_user_report_filters_large_message(default_project, monkeypatch):
116117
if not max_length:
117118
assert False, "Missing max_length for UserReport comments field!"
118119

119-
report = {
120+
report: UserReportDict = {
120121
"event_id": "123456",
121122
"name": "Test User",
122123
"email": "test@example.com",
@@ -145,7 +146,7 @@ def test_save_user_report_shims_if_event_found(default_project, monkeypatch):
145146
mock_shim_to_feedback = Mock()
146147
monkeypatch.setattr("sentry.ingest.userreport.shim_to_feedback", mock_shim_to_feedback)
147148

148-
report = {
149+
report: UserReportDict = {
149150
"event_id": event.event_id,
150151
"name": "Test User",
151152
"email": "test@example.com",
@@ -176,7 +177,7 @@ def test_save_user_report_does_not_shim_if_event_found_but_source_is_new_feedbac
176177
mock_shim_to_feedback = Mock()
177178
monkeypatch.setattr("sentry.ingest.userreport.shim_to_feedback", mock_shim_to_feedback)
178179

179-
report = {
180+
report: UserReportDict = {
180181
"event_id": event.event_id,
181182
"name": "Test User",
182183
"email": "test@example.com",

0 commit comments

Comments
 (0)