Skip to content
14 changes: 12 additions & 2 deletions src/sentry/feedback/usecases/create_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,15 @@ def should_filter_feedback(
return False, None


def create_feedback_issue(event, project_id: int, source: FeedbackCreationSource):
def create_feedback_issue(
event: dict[str, Any], project_id: int, source: FeedbackCreationSource
) -> dict[str, Any] | None:
"""
Produces a feedback issue occurrence to kafka for issues processing. Applies filters, spam filters, and event validation.

Returns the formatted event data that was sent to issue platform.
"""

metrics.incr(
"feedback.create_feedback_issue.entered",
tags={
Expand All @@ -306,7 +314,7 @@ def create_feedback_issue(event, project_id: int, source: FeedbackCreationSource
category=DataCategory.USER_REPORT_V2,
quantity=1,
)
return
return None

feedback_message = event["contexts"]["feedback"]["message"]

Expand Down Expand Up @@ -409,6 +417,8 @@ def create_feedback_issue(event, project_id: int, source: FeedbackCreationSource
quantity=1,
)

return event_fixed


def auto_ignore_spam_feedbacks(project, issue_fingerprint):
"""
Expand Down
66 changes: 66 additions & 0 deletions src/sentry/feedback/usecases/save_feedback_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import logging
from collections.abc import Mapping
from datetime import UTC, datetime
from typing import Any

from sentry.feedback.usecases.create_feedback import FeedbackCreationSource, create_feedback_issue
from sentry.ingest.userreport import save_userreport
from sentry.models.project import Project
from sentry.utils import metrics

logger = logging.getLogger(__name__)


def save_feedback_event(event_data: Mapping[str, Any], project_id: int):
"""Saves a feedback from a feedback event envelope.

If the save is successful and the `associated_event_id` field is present, this will
also save a UserReport in Postgres. This is to ensure the feedback can be queried by
group_id, which is hard to associate in clickhouse.
"""
if not isinstance(event_data, dict):
event_data = dict(event_data)

# Produce to issue platform
fixed_event_data = create_feedback_issue(
event_data, project_id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
)
if not fixed_event_data:
return

try:
# Shim to UserReport
feedback_context = fixed_event_data["contexts"]["feedback"]
associated_event_id = feedback_context.get("associated_event_id")
if associated_event_id:
project = Project.objects.get_from_cache(id=project_id)
save_userreport(
project,
{
"event_id": associated_event_id,
"project_id": project_id,
# XXX(aliu): including environment ensures the update_user_reports task
# will not shim the report back to feedback.
"environment_id": fixed_event_data.get("environment", "production"),
"name": feedback_context["name"],
"email": feedback_context["contact_email"],
"comments": feedback_context["message"],
},
FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE,
start_time=datetime.fromtimestamp(fixed_event_data["timestamp"], UTC),
)
metrics.incr("feedback.shim_to_userreport.success")

except Exception:
metrics.incr("feedback.shim_to_userreport.failed")
logger.exception(
"Error shimming from feedback event to user report.",
extra={
"associated_event_id": associated_event_id,
"project_id": project_id,
"environment_id": fixed_event_data.get("environment"),
"username": feedback_context.get("name"),
"email": feedback_context.get("contact_email"),
"comments": feedback_context.get("message"),
},
)
10 changes: 8 additions & 2 deletions src/sentry/ingest/userreport.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ def save_userreport(
report["event_id"] = report["event_id"].lower()
report["project_id"] = project.id

event = eventstore.backend.get_event_by_id(project.id, report["event_id"])
# Use the associated event to validate and update the report.
event: Event | GroupEvent | None = eventstore.backend.get_event_by_id(
project.id, report["event_id"]
)

euser = find_event_user(event)

Expand All @@ -99,6 +102,7 @@ def save_userreport(
report["environment_id"] = event.get_environment().id
report["group_id"] = event.group_id

# Save the report.
try:
with atomic_transaction(using=router.db_for_write(UserReport)):
report_instance = UserReport.objects.create(**report)
Expand Down Expand Up @@ -136,6 +140,7 @@ def save_userreport(
if report_instance.group_id:
report_instance.notify()

# Additionally processing if save is successful.
user_feedback_received.send_robust(project=project, sender=save_userreport)

logger.info(
Expand All @@ -150,12 +155,13 @@ def save_userreport(
"user_report.create_user_report.saved",
tags={"has_event": bool(event), "referrer": source.value},
)
if event:
if event and source.value in FeedbackCreationSource.old_feedback_category_values():
logger.info(
"ingest.user_report.shim_to_feedback",
extra={"project_id": project.id, "event_id": report["event_id"]},
)
shim_to_feedback(report, event, project, source)
# XXX(aliu): the update_user_reports task will still try to shim the report after a period, unless group_id or environment is set.

return report_instance

Expand Down
35 changes: 18 additions & 17 deletions src/sentry/tasks/post_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1410,25 +1410,26 @@ def link_event_to_user_report(job: PostProcessJob) -> None:
event = job["event"]
project = event.project
user_reports_without_group = UserReport.objects.filter(
project_id=project.id,
event_id=event.event_id,
group_id__isnull=True,
environment_id__isnull=True,
project_id=project.id, event_id=event.event_id, group_id__isnull=True
)
for report in user_reports_without_group:
shim_to_feedback(
{
"name": report.name,
"email": report.email,
"comments": report.comments,
"event_id": report.event_id,
"level": "error",
},
event,
project,
FeedbackCreationSource.USER_REPORT_ENVELOPE,
)
metrics.incr("event_manager.save._update_user_reports_with_event_link.shim_to_feedback")
if report.environment_id is None:
shim_to_feedback(
{
"name": report.name,
"email": report.email,
"comments": report.comments,
"event_id": report.event_id,
"level": "error",
},
event,
project,
FeedbackCreationSource.USER_REPORT_ENVELOPE,
)
metrics.incr(
"event_manager.save._update_user_reports_with_event_link.shim_to_feedback"
)
# If environment is set, this report was already shimmed from new feedback.

user_reports_updated = user_reports_without_group.update(
group_id=group.id, environment_id=event.get_environment().id
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/tasks/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from sentry.constants import DEFAULT_STORE_NORMALIZER_ARGS
from sentry.datascrubbing import scrub_data
from sentry.eventstore import processing
from sentry.feedback.usecases.create_feedback import FeedbackCreationSource, create_feedback_issue
from sentry.feedback.usecases.save_feedback_event import save_feedback_event
from sentry.ingest.types import ConsumerType
from sentry.killswitches import killswitch_matches_context
from sentry.lang.native.symbolicator import SymbolicatorTaskKind
Expand Down Expand Up @@ -690,7 +690,7 @@ def save_event_feedback(
project_id: int,
**kwargs: Any,
) -> None:
create_feedback_issue(data, project_id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE)
save_feedback_event(data, project_id)


@instrumented_task(
Expand Down
41 changes: 21 additions & 20 deletions src/sentry/tasks/update_user_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,12 @@ def update_user_reports(**kwargs: Any) -> None:
# ingestion time
user_reports = UserReport.objects.filter(
group_id__isnull=True,
environment_id__isnull=True,
date_added__gte=start,
date_added__lte=end,
)

# We do one query per project, just to avoid the small case that two projects have the same event ID
project_map: dict[int, Any] = {}
project_map: dict[int, list[UserReport]] = {}
for r in user_reports:
project_map.setdefault(r.project_id, []).append(r)

Expand Down Expand Up @@ -90,24 +89,26 @@ def update_user_reports(**kwargs: Any) -> None:
for event in events:
report = report_by_event.get(event.event_id)
if report:
if not is_in_feedback_denylist(project.organization):
logger.info(
"update_user_reports.shim_to_feedback",
extra={"report_id": report.id, "event_id": event.event_id},
)
metrics.incr("tasks.update_user_reports.shim_to_feedback")
shim_to_feedback(
{
"name": report.name,
"email": report.email,
"comments": report.comments,
"event_id": report.event_id,
"level": "error",
},
event,
project,
FeedbackCreationSource.UPDATE_USER_REPORTS_TASK,
)
if report.environment_id is None:
if not is_in_feedback_denylist(project.organization):
logger.info(
"update_user_reports.shim_to_feedback",
extra={"report_id": report.id, "event_id": event.event_id},
)
metrics.incr("tasks.update_user_reports.shim_to_feedback")
shim_to_feedback(
{
"name": report.name,
"email": report.email,
"comments": report.comments,
"event_id": report.event_id,
"level": "error",
},
event,
project,
FeedbackCreationSource.UPDATE_USER_REPORTS_TASK,
)
# XXX(aliu): If a report has environment_id but not group_id, this report was shimmed from a feedback issue, so no need to shim again.
report.update(group_id=event.group_id, environment_id=event.get_environment().id)
updated_reports += 1

Expand Down
15 changes: 9 additions & 6 deletions tests/sentry/feedback/usecases/test_create_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ def create_dummy_response(*args, **kwargs):
)


def mock_feedback_event(project_id: int, dt: datetime):
def mock_feedback_event(project_id: int, dt: datetime | None = None):
if dt is None:
dt = datetime.now(UTC)

return {
"project_id": project_id,
"request": {
Expand Down Expand Up @@ -773,7 +776,7 @@ def test_create_feedback_adds_associated_event_id(
@django_db_all
def test_create_feedback_tags(default_project, mock_produce_occurrence_to_kafka):
"""We want to surface these tags in the UI. We also use user.email for alert conditions."""
event = mock_feedback_event(default_project.id, datetime.now(UTC))
event = mock_feedback_event(default_project.id)
event["user"]["email"] = "josh.ferge@sentry.io"
event["contexts"]["feedback"]["contact_email"] = "andrew@sentry.io"
event["contexts"]["trace"] = {"trace_id": "abc123"}
Expand All @@ -796,7 +799,7 @@ def test_create_feedback_tags(default_project, mock_produce_occurrence_to_kafka)

@django_db_all
def test_create_feedback_tags_skips_if_empty(default_project, mock_produce_occurrence_to_kafka):
event = mock_feedback_event(default_project.id, datetime.now(UTC))
event = mock_feedback_event(default_project.id)
event["user"].pop("email", None)
event["contexts"]["feedback"].pop("contact_email", None)
create_feedback_issue(event, default_project.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE)
Expand Down Expand Up @@ -826,7 +829,7 @@ def test_create_feedback_filters_large_message(
monkeypatch.setattr("sentry.llm.usecases.complete_prompt", mock_complete_prompt)

with Feature(features), set_sentry_option("feedback.message.max-size", 4096):
event = mock_feedback_event(default_project.id, datetime.now(UTC))
event = mock_feedback_event(default_project.id)
event["contexts"]["feedback"]["message"] = "a" * 7007
create_feedback_issue(
event, default_project.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
Expand All @@ -839,7 +842,7 @@ def test_create_feedback_filters_large_message(
@django_db_all
def test_create_feedback_evidence_has_source(default_project, mock_produce_occurrence_to_kafka):
"""We need this evidence field in post process, to determine if we should send alerts."""
event = mock_feedback_event(default_project.id, datetime.now(UTC))
event = mock_feedback_event(default_project.id)
source = FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
create_feedback_issue(event, default_project.id, source)

Expand All @@ -857,7 +860,7 @@ def test_create_feedback_evidence_has_spam(
default_project.update_option("sentry:feedback_ai_spam_detection", True)

with Feature({"organizations:user-feedback-spam-filter-ingest": True}):
event = mock_feedback_event(default_project.id, datetime.now(UTC))
event = mock_feedback_event(default_project.id)
source = FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
create_feedback_issue(event, default_project.id, source)

Expand Down
66 changes: 66 additions & 0 deletions tests/sentry/feedback/usecases/test_save_feedback_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from datetime import UTC, datetime
from unittest import mock

import pytest

from sentry.feedback.usecases.create_feedback import FeedbackCreationSource
from sentry.feedback.usecases.save_feedback_event import save_feedback_event
from sentry.testutils.pytest.fixtures import django_db_all
from tests.sentry.feedback.usecases.test_create_feedback import mock_feedback_event


@pytest.fixture
def mock_create_feedback_issue():
with mock.patch("sentry.feedback.usecases.save_feedback_event.create_feedback_issue") as m:
yield m


@pytest.fixture
def mock_save_userreport():
with mock.patch("sentry.feedback.usecases.save_feedback_event.save_userreport") as m:
yield m


@django_db_all
def test_save_feedback_event_no_associated_error(
default_project, mock_create_feedback_issue, mock_save_userreport
):
event_data = mock_feedback_event(default_project.id)
mock_create_feedback_issue.return_value = None

save_feedback_event(event_data, default_project.id)

mock_create_feedback_issue.assert_called_once_with(
event_data, default_project.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
)
mock_save_userreport.assert_not_called()


@django_db_all
def test_save_feedback_event_with_associated_error(
default_project, mock_create_feedback_issue, mock_save_userreport
):
event_data = mock_feedback_event(default_project.id)
event_data["contexts"]["feedback"]["associated_event_id"] = "abcd" * 8
mock_create_feedback_issue.return_value = event_data

save_feedback_event(event_data, default_project.id)

mock_create_feedback_issue.assert_called_once_with(
event_data, default_project.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
)

feedback_context = event_data["contexts"]["feedback"]
mock_save_userreport.assert_called_once_with(
default_project,
{
"event_id": "abcd" * 8,
"project_id": default_project.id,
"environment_id": event_data["environment"],
"name": feedback_context["name"],
"email": feedback_context["contact_email"],
"comments": feedback_context["message"],
},
FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE,
start_time=datetime.fromtimestamp(event_data["timestamp"], UTC),
)
Loading
Loading