diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 25d51e875af5..e6ef1d0247d0 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -384,6 +384,8 @@ def register_temporary_features(manager: FeatureManager) -> None: # Use batched Snuba queries for weekly report key errors instead of per-project queries manager.add("organizations:weekly-report-batched-key-errors", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Show combined resolved "past issues" section instead of separate key errors / performance issues + manager.add("organizations:weekly-report-past-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable week-over-week percentage change metric in weekly email reports manager.add("organizations:weekly-report-week-over-week-metric", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable logging to debug workflow engine process workflows diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index 5dac5652f65c..2c30a7d1e06c 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -787,6 +787,7 @@ class Referrer(StrEnum): REPORTS_KEY_ERRORS = "reports.key_errors" REPORTS_KEY_ERRORS_BATCHED = "reports.key_errors.batched" REPORTS_KEY_PERFORMANCE_ISSUES = "reports.key_performance_issues" + REPORTS_PAST_RESOLVED_ISSUES = "reports.past_resolved_issues" REPORTS_KEY_TRANSACTIONS_THIS_WEEK = "reports.key_transactions.this_week" REPORTS_KEY_TRANSACTIONS_LAST_WEEK = "reports.key_transactions.last_week" REPORTS_OUTCOME_SERIES = "reports.outcome_series" diff --git a/src/sentry/tasks/summaries/organization_report_context_factory.py b/src/sentry/tasks/summaries/organization_report_context_factory.py index 23a373737b18..551be40388bb 100644 --- a/src/sentry/tasks/summaries/organization_report_context_factory.py +++ b/src/sentry/tasks/summaries/organization_report_context_factory.py @@ -11,6 +11,7 @@ ProjectContext, fetch_key_error_groups, fetch_key_performance_issue_groups, + fetch_past_resolved_issue_links, org_key_errors, organization_project_issue_substatus_summaries, project_event_counts_for_organization, @@ -18,6 +19,7 @@ project_key_performance_issues, project_key_transactions_last_week, project_key_transactions_this_week, + project_past_resolved_issues, ) from sentry.tasks.summaries.weekly_report_cache import read_project_metrics from sentry.utils import metrics @@ -214,6 +216,21 @@ def _hydrate_key_performance_issue_groups(self, ctx: OrganizationReportContext) with sentry_sdk.start_span(op="weekly_reports.fetch_key_performance_issue_groups"): fetch_key_performance_issue_groups(ctx) + @metrics.wraps("weekly_report.create_context.project_past_resolved_issues") + def _append_project_past_resolved_issues(self, ctx: OrganizationReportContext) -> None: + with sentry_sdk.start_span(op="weekly_reports.project_past_resolved_issues"): + for project in ctx.organization.project_set.all(): + if project.id not in ctx.projects_context_map: + continue + project_ctx = ctx.projects_context_map[project.id] + resolved = project_past_resolved_issues( + ctx, project, referrer=Referrer.REPORTS_PAST_RESOLVED_ISSUES.value + ) + if resolved: + project_ctx.past_resolved_issues = resolved + + fetch_past_resolved_issue_links(ctx) + def create_context(self) -> OrganizationReportContext: ctx = OrganizationReportContext(self.timestamp, self.duration, self.organization) @@ -234,5 +251,7 @@ def create_context(self) -> OrganizationReportContext: self._append_project_key_errors(ctx) self._hydrate_key_error_groups(ctx) self._hydrate_key_performance_issue_groups(ctx) + if features.has("organizations:weekly-report-past-issues", self.organization): + self._append_project_past_resolved_issues(ctx) return ctx diff --git a/src/sentry/tasks/summaries/utils.py b/src/sentry/tasks/summaries/utils.py index 2ff8eb191358..f41df84c018c 100644 --- a/src/sentry/tasks/summaries/utils.py +++ b/src/sentry/tasks/summaries/utils.py @@ -16,8 +16,14 @@ from snuba_sdk.relationships import Relationship from sentry.constants import DataCategory +from sentry.issues.grouptype import ( + PERFORMANCE_ISSUE_CATEGORIES, + GroupCategory, + InvalidGroupTypeError, +) from sentry.models.group import Group, GroupStatus from sentry.models.grouphistory import GroupHistory +from sentry.models.grouplink import GroupLink from sentry.models.organization import Organization from sentry.models.organizationmember import OrganizationMember from sentry.models.project import Project @@ -87,6 +93,8 @@ def __init__(self, project): self.key_transactions = [] # Array of (Group, count) self.key_performance_issues = [] + # Array of (Group, event_count, has_linked_pr_or_commit) + self.past_resolved_issues: list[tuple[Group, int, bool]] = [] self.key_replay_events = [] @@ -109,6 +117,7 @@ def check_if_project_is_empty(self): not self.key_errors_by_group and not self.key_transactions and not self.key_performance_issues + and not self.past_resolved_issues and not self.accepted_error_count and not self.accepted_transaction_count ) @@ -737,3 +746,186 @@ def organization_project_issue_substatus_summaries(ctx: OrganizationReportContex if item["substatus"] == GroupSubStatus.REGRESSED: project_ctx.regression_substatus_count = item["total"] project_ctx.total_substatus_count += item["total"] + + +PAST_ISSUES_CANDIDATE_LIMIT = 50 +PAST_ISSUES_LINK_BOOST = 2 + + +def project_past_resolved_issues( + ctx: OrganizationReportContext, project: Project, referrer: str +) -> list[tuple[Group, int, bool]]: + if not project.first_event: + return [] + + with sentry_sdk.start_span(op="weekly_reports.project_past_resolved_issues"): + candidates = list( + Group.objects.filter( + project_id=project.id, + status=GroupStatus.RESOLVED, + resolved_at__gte=ctx.start, + resolved_at__lt=ctx.end + timedelta(days=1), + ).order_by("-times_seen")[:PAST_ISSUES_CANDIDATE_LIMIT] + ) + + if not candidates: + return [] + + # Filter out groups with unregistered type IDs (deprecated/removed issue types) + valid_candidates = [] + for g in candidates: + if g.type is None: + valid_candidates.append(g) + continue + try: + g.issue_category + except InvalidGroupTypeError: + continue + valid_candidates.append(g) + + group_id_to_group = {g.id: g for g in valid_candidates} + + # Legacy groups may have a None .type which crashes issue_category; treat as error group + error_group_ids = [ + g.id + for g in valid_candidates + if g.type is None or g.issue_category == GroupCategory.ERROR + ] + perf_group_ids = [ + g.id + for g in valid_candidates + if g.type is not None + and ( + g.issue_category == GroupCategory.PERFORMANCE + or g.issue_category in PERFORMANCE_ISSUE_CATEGORIES + ) + ] + + event_counts: dict[int, int] = {} + + if error_group_ids: + error_counts = _past_resolved_error_counts(ctx, project, error_group_ids, referrer) + event_counts.update(error_counts) + + if perf_group_ids: + perf_counts = _past_resolved_perf_counts(ctx, project, perf_group_ids, referrer) + event_counts.update(perf_counts) + + # has_link is initially False; updated by fetch_past_resolved_issue_links at org level + scored = [] + for group_id, count in event_counts.items(): + group = group_id_to_group.get(group_id) + if group is None: + continue + scored.append((group, count, False)) + + scored.sort(key=lambda x: x[1], reverse=True) + return scored + + +def _past_resolved_error_counts( + ctx: OrganizationReportContext, + project: Project, + group_ids: list[int], + referrer: str, +) -> dict[int, int]: + events_entity = Entity("events", alias="events") + group_attributes_entity = Entity("group_attributes", alias="group_attributes") + query = Query( + match=Join([Relationship(events_entity, "attributes", group_attributes_entity)]), + select=[Column("group_id", entity=events_entity), Function("count", [])], + where=[ + Condition(Column("timestamp", entity=events_entity), Op.GTE, ctx.start), + Condition( + Column("timestamp", entity=events_entity), + Op.LT, + ctx.end + timedelta(days=1), + ), + Condition(Column("project_id", entity=events_entity), Op.EQ, project.id), + Condition(Column("project_id", entity=group_attributes_entity), Op.EQ, project.id), + Condition( + Column("group_id", entity=events_entity), + Op.IN, + group_ids, + ), + Condition( + Column("group_status", entity=group_attributes_entity), + Op.EQ, + GroupStatus.RESOLVED, + ), + ], + groupby=[Column("group_id", entity=events_entity)], + orderby=[OrderBy(Function("count", []), Direction.DESC)], + limit=Limit(len(group_ids)), + ) + + request = Request( + dataset=Dataset.Events.value, + app_id="reports", + query=query, + tenant_ids={"organization_id": ctx.organization.id}, + ) + rows = raw_snql_query(request, referrer=referrer)["data"] + return {row["events.group_id"]: row["count()"] for row in rows} + + +def _past_resolved_perf_counts( + ctx: OrganizationReportContext, + project: Project, + group_ids: list[int], + referrer: str, +) -> dict[int, int]: + query = Query( + match=Entity("search_issues"), + select=[Column("group_id"), Function("count", [])], + where=[ + Condition(Column("group_id"), Op.IN, group_ids), + Condition(Column("timestamp"), Op.GTE, ctx.start), + Condition(Column("timestamp"), Op.LT, ctx.end + timedelta(days=1)), + Condition(Column("project_id"), Op.EQ, project.id), + ], + groupby=[Column("group_id")], + orderby=[OrderBy(Function("count", []), Direction.DESC)], + limit=Limit(len(group_ids)), + ) + request = Request( + dataset=Dataset.IssuePlatform.value, + app_id="reports", + query=query, + tenant_ids={"organization_id": ctx.organization.id}, + ) + rows = raw_snql_query(request, referrer=referrer)["data"] + return {row["group_id"]: row["count()"] for row in rows} + + +def fetch_past_resolved_issue_links(ctx: OrganizationReportContext) -> None: + all_group_ids: list[int] = [] + for project_ctx in ctx.projects_context_map.values(): + all_group_ids.extend( + group.id for group, _count, _has_link in project_ctx.past_resolved_issues + ) + + if not all_group_ids: + return + + groups_with_links = set( + GroupLink.objects.filter( + group_id__in=all_group_ids, + linked_type__in=[GroupLink.LinkedType.commit, GroupLink.LinkedType.pull_request], + relationship=GroupLink.Relationship.resolves, + ).values_list("group_id", flat=True) + ) + + for project_ctx in ctx.projects_context_map.values(): + project_ctx.past_resolved_issues = [ + (group, count, group.id in groups_with_links) + for group, count, _has_link in project_ctx.past_resolved_issues + ] + + # Re-sort with link boost applied, then truncate to top 3 + for project_ctx in ctx.projects_context_map.values(): + project_ctx.past_resolved_issues.sort( + key=lambda x: x[1] * (PAST_ISSUES_LINK_BOOST if x[2] else 1), + reverse=True, + ) + project_ctx.past_resolved_issues = project_ctx.past_resolved_issues[:3] diff --git a/src/sentry/tasks/summaries/weekly_reports.py b/src/sentry/tasks/summaries/weekly_reports.py index cde6f96e5927..61e54e36d27e 100644 --- a/src/sentry/tasks/summaries/weekly_reports.py +++ b/src/sentry/tasks/summaries/weekly_reports.py @@ -36,7 +36,7 @@ from sentry.tasks.summaries.organization_report_context_factory import ( OrganizationReportContextFactory, ) -from sentry.tasks.summaries.utils import ONE_DAY, OrganizationReportContext +from sentry.tasks.summaries.utils import ONE_DAY, PAST_ISSUES_LINK_BOOST, OrganizationReportContext from sentry.tasks.summaries.weekly_report_cache import cache_project_metrics from sentry.taskworker.namespaces import reports_tasks from sentry.types.group import GroupSubStatus @@ -780,6 +780,23 @@ def all_key_performance_issues(): return heapq.nlargest(3, all_key_performance_issues(), lambda d: d["count"]) + def past_issues(): + def all_past_issues(): + for project_ctx in user_projects: + for group, count, has_linked_pr_or_commit in project_ctx.past_resolved_issues: + display = get_group_display(group) + yield { + "count": count, + "group": group, + "title": display["title"], + "message": display["message"], + "has_linked_pr_or_commit": has_linked_pr_or_commit, + "_relevance": count + * (PAST_ISSUES_LINK_BOOST if has_linked_pr_or_commit else 1), + } + + return heapq.nlargest(3, all_past_issues(), lambda d: d["_relevance"]) + def issue_summary(): new_substatus_count = 0 escalating_substatus_count = 0 @@ -800,6 +817,8 @@ def issue_summary(): "total_substatus_count": total_substatus_count, } + show_past_issues = features.has("organizations:weekly-report-past-issues", ctx.organization) + return { "organization": ctx.organization, "start": date_format(local_start), @@ -808,6 +827,8 @@ def issue_summary(): "key_errors": key_errors(), "key_transactions": key_transactions(), "key_performance_issues": key_performance_issues(), + "past_issues": past_issues() if show_past_issues else [], + "show_past_issues": show_past_issues, "issue_summary": issue_summary(), "user_project_count": len(user_projects), "notification_uuid": notification_uuid, diff --git a/src/sentry/templates/sentry/emails/reports/body.html b/src/sentry/templates/sentry/emails/reports/body.html index 50a37651ca57..e43f1f1471b6 100644 --- a/src/sentry/templates/sentry/emails/reports/body.html +++ b/src/sentry/templates/sentry/emails/reports/body.html @@ -470,6 +470,25 @@