Skip to content

feat(feedback): cache the feedback summary #93461

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 18, 2025
Merged
45 changes: 42 additions & 3 deletions src/sentry/api/endpoints/organization_feedback_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
from sentry.api.utils import get_date_range_from_stats_period
from sentry.exceptions import InvalidParams
from sentry.feedback.usecases.feedback_summaries import generate_summary
from sentry.grouping.utils import hash_from_values
from sentry.issues.grouptype import FeedbackGroup
from sentry.models.group import Group, GroupStatus
from sentry.models.organization import Organization
from sentry.utils.cache import cache

logger = logging.getLogger(__name__)

Expand All @@ -24,6 +26,9 @@
# Token limit is 1,048,576 tokens, see https://ai.google.dev/gemini-api/docs/models#gemini-2.0-flash
MAX_FEEDBACKS_TO_SUMMARIZE_CHARS = 1000000

# One day since the cache key includes the start and end dates at hour granularity
SUMMARY_CACHE_TIMEOUT = 86400


@region_silo_endpoint
class OrganizationFeedbackSummaryEndpoint(OrganizationEndpoint):
Expand Down Expand Up @@ -63,12 +68,30 @@ def get(self, request: Request, organization: Organization) -> Response:
except InvalidParams:
raise ParseError(detail="Invalid or missing date range")

projects = self.get_projects(request, organization)

# Sort first, then convert each element to a string
numeric_project_ids = sorted([project.id for project in projects])
project_ids = [str(project_id) for project_id in numeric_project_ids]
hashed_project_ids = hash_from_values(project_ids)

summary_cache_key = f"feedback_summary:{organization.id}:{start.strftime('%Y-%m-%d-%H')}:{end.strftime('%Y-%m-%d-%H')}:{hashed_project_ids}"
summary_cache = cache.get(summary_cache_key)
if summary_cache:
return Response(
{
"summary": summary_cache["summary"],
"success": True,
"numFeedbacksUsed": summary_cache["numFeedbacksUsed"],
}
)

filters = {
"type": FeedbackGroup.type_id,
"first_seen__gte": start,
"first_seen__lte": end,
"status": GroupStatus.UNRESOLVED,
"project__in": self.get_projects(request, organization),
"project__in": projects,
}

groups = Group.objects.filter(**filters).order_by("-first_seen")[
Expand All @@ -77,7 +100,13 @@ def get(self, request: Request, organization: Organization) -> Response:

if groups.count() < MIN_FEEDBACKS_TO_SUMMARIZE:
logger.error("Too few feedbacks to summarize")
return Response({"summary": None, "success": False, "numFeedbacksUsed": 0})
return Response(
{
"summary": None,
"success": False,
"numFeedbacksUsed": 0,
}
)

# Also cap the number of characters that we send to the LLM
group_feedbacks = []
Expand All @@ -99,6 +128,16 @@ def get(self, request: Request, organization: Organization) -> Response:
logger.exception("Error generating summary of user feedbacks")
return Response({"detail": "Error generating summary"}, status=500)

cache.set(
summary_cache_key,
{"summary": summary, "numFeedbacksUsed": len(group_feedbacks)},
timeout=SUMMARY_CACHE_TIMEOUT,
)

return Response(
{"summary": summary, "success": True, "numFeedbacksUsed": len(group_feedbacks)}
{
"summary": summary,
"success": True,
"numFeedbacksUsed": len(group_feedbacks),
}
)
52 changes: 52 additions & 0 deletions tests/sentry/api/endpoints/test_organization_feedback_summary.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,55 @@ def test_get_feedback_summary_character_limit(self, mock_generate_summary):
assert response.data["success"] is True
assert response.data["summary"] == "Test summary of feedback"
assert response.data["numFeedbacksUsed"] == 12

@django_db_all
@patch(
"sentry.api.endpoints.organization_feedback_summary.generate_summary",
return_value="Test summary of feedback",
)
@patch("sentry.api.endpoints.organization_feedback_summary.cache")
def test_get_feedback_summary_cache_hit(self, mock_cache, mock_generate_summary):
mock_cache.get.return_value = {
"summary": "Test cached summary of feedback",
"numFeedbacksUsed": 13,
}

for _ in range(15):
event = mock_feedback_event(self.project1.id)
create_feedback_issue(
event, self.project1.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
)

with self.feature(self.features):
response = self.get_success_response(self.org.slug)

assert response.data["success"] is True
assert response.data["summary"] == "Test cached summary of feedback"
assert response.data["numFeedbacksUsed"] == 13

mock_cache.get.assert_called_once()
mock_cache.set.assert_not_called()

@django_db_all
@patch(
"sentry.api.endpoints.organization_feedback_summary.generate_summary",
return_value="Test summary of feedback",
)
@patch("sentry.api.endpoints.organization_feedback_summary.cache")
def test_get_feedback_summary_cache_miss(self, mock_cache, mock_generate_summary):
mock_cache.get.return_value = None

for _ in range(15):
event = mock_feedback_event(self.project1.id)
create_feedback_issue(
event, self.project1.id, FeedbackCreationSource.NEW_FEEDBACK_ENVELOPE
)

with self.feature(self.features):
response = self.get_success_response(self.org.slug)

assert response.data["success"] is True
assert response.data["summary"] == "Test summary of feedback"
assert response.data["numFeedbacksUsed"] == 15
mock_cache.get.assert_called_once()
mock_cache.set.assert_called_once()
Loading