diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 2b2c0787d4ae..9004dd954218 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -24,6 +24,14 @@ from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse from sentry.hybridcloud.rpc import coerce_id_from from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs +from sentry.issues.action_log import ( + ActionType, + action_context_scope, + get_action_context, + publish_action, + publish_action_from_context, + resolve_action_source, +) from sentry.issues.grouptype import GroupCategory from sentry.issues.ignored import handle_archived_until_escalating, handle_ignored from sentry.issues.merge import MergedGroup, handle_merge @@ -205,64 +213,74 @@ def update_groups( if discard: return handle_discard(request, groups, projects, acting_user) - status_details = result.pop("statusDetails", result) - status = result.get("status") - res_type = None - if "priority" in result: - if any(not group.issue_type.enable_user_status_and_priority_changes for group in groups): - return Response( - {"detail": "Cannot manually set priority of one or more issues."}, - status=HTTPStatus.BAD_REQUEST, - ) + source = resolve_action_source(request) + actor_id = acting_user.id if acting_user else None + + with action_context_scope(source=source, actor_id=actor_id): + status_details = result.pop("statusDetails", result) + status = result.get("status") + res_type = None + if "priority" in result: + if any( + not group.issue_type.enable_user_status_and_priority_changes for group in groups + ): + return Response( + {"detail": "Cannot manually set priority of one or more issues."}, + status=HTTPStatus.BAD_REQUEST, + ) - handle_priority( - priority=result["priority"], - group_list=groups, - acting_user=acting_user, - project_lookup=project_lookup, - ) - if status in ("resolved", "resolvedInNextRelease"): - if any(not group.issue_type.enable_user_status_and_priority_changes for group in groups): - return Response( - {"detail": "Cannot manually resolve one or more issues."}, - status=HTTPStatus.BAD_REQUEST, + handle_priority( + priority=result["priority"], + group_list=groups, + acting_user=acting_user, + project_lookup=project_lookup, ) + if status in ("resolved", "resolvedInNextRelease"): + if any( + not group.issue_type.enable_user_status_and_priority_changes for group in groups + ): + return Response( + {"detail": "Cannot manually resolve one or more issues."}, + status=HTTPStatus.BAD_REQUEST, + ) - try: - result, res_type = handle_resolve_in_release( - status, - status_details, + try: + result, res_type = handle_resolve_in_release( + status, + status_details, + groups, + projects, + project_lookup, + acting_user, + result, + ) + except MultipleProjectsError: + return Response( + {"detail": "Cannot set resolved for multiple projects."}, status=400 + ) + elif status: + result = handle_other_status_updates( + result, groups, projects, project_lookup, + status_details, acting_user, - result, ) - except MultipleProjectsError: - return Response({"detail": "Cannot set resolved for multiple projects."}, status=400) - elif status: - result = handle_other_status_updates( + + return prepare_response( + request, result, groups, - projects, project_lookup, - status_details, + projects, acting_user, + data, + res_type, + request.META.get("HTTP_REFERER", ""), + organization, ) - return prepare_response( - request, - result, - groups, - project_lookup, - projects, - acting_user, - data, - res_type, - request.META.get("HTTP_REFERER", ""), - organization, - ) - def update_groups_with_search_fn( request: Request, @@ -628,6 +646,12 @@ def process_group_resolution( data=dict(activity_data), ) record_group_history_from_activity_type(group, activity_type, actor=acting_user) + publish_action_from_context( + action=ActionType.RESOLVE, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + ) # TODO(dcramer): we need a solution for activity rollups # before sending notifications on bulk changes @@ -794,6 +818,33 @@ def prepare_response( acting_user, urlparse(referer).path, ) + ctx = get_action_context() + if ctx is not None and isinstance(result["merge"], dict): + merged = result["merge"] + primary_id = int(merged["parent"]) + child_ids = [int(c) for c in merged["children"]] + group_by_id = {g.id: g for g in group_list} + primary = group_by_id[primary_id] + publish_action( + action=ActionType.MERGE_FROM_OTHER, + source=ctx.source, + group_id=primary_id, + organization_id=primary.project.organization_id, + project_id=primary.project_id, + actor_id=ctx.actor_id, + metadata={"counterpart_group_ids": child_ids}, + ) + for child_id in child_ids: + child = group_by_id[child_id] + publish_action( + action=ActionType.MERGE_INTO_OTHER, + source=ctx.source, + group_id=child_id, + organization_id=child.project.organization_id, + project_id=child.project_id, + actor_id=ctx.actor_id, + metadata={"counterpart_group_id": primary_id}, + ) inbox = result.get("inbox", None) if inbox is not None: diff --git a/src/sentry/integrations/utils/sync.py b/src/sentry/integrations/utils/sync.py index 5bd44383026f..7b55327e7f31 100644 --- a/src/sentry/integrations/utils/sync.py +++ b/src/sentry/integrations/utils/sync.py @@ -19,6 +19,7 @@ from sentry.integrations.services.assignment_source import AssignmentSource from sentry.integrations.tasks.sync_assignee_outbound import sync_assignee_outbound from sentry.integrations.types import EXTERNAL_PROVIDERS_REVERSE, ExternalProviderEnum +from sentry.issues.action_log import action_context_scope from sentry.models.group import Group from sentry.models.groupassignee import GroupAssignee from sentry.models.organization import Organization @@ -82,10 +83,11 @@ def _handle_deassign( if not should_sync_assignee_inbound(group.organization, integration.provider): continue - GroupAssignee.objects.deassign( - group, - assignment_source=AssignmentSource.from_integration(integration), - ) + with action_context_scope(source=integration.provider, actor_id=None): + GroupAssignee.objects.deassign( + group, + assignment_source=AssignmentSource.from_integration(integration), + ) groups_deassigned.append(group) return groups_deassigned @@ -116,11 +118,12 @@ def _handle_assign( "user_id": user.id, }, ) - GroupAssignee.objects.assign( - group, - user, - assignment_source=AssignmentSource.from_integration(integration), - ) + with action_context_scope(source=integration.provider, actor_id=None): + GroupAssignee.objects.assign( + group, + user, + assignment_source=AssignmentSource.from_integration(integration), + ) groups_assigned.append(group) else: logger.info( diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py new file mode 100644 index 000000000000..90808a62c801 --- /dev/null +++ b/src/sentry/issues/action_log.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import logging +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from rest_framework.request import Request + +from sentry.middleware import is_frontend_request +from sentry.utils import metrics + +logger = logging.getLogger(__name__) + +# Group Action Log — experimental module for tracking who did what to an issue and how. +# Storage backend not yet wired up; actions are emitted as structured logs and metrics only. +# +# Most mutation sites should use publish_action_from_context(), which reads attribution +# from a ContextVar set at the request boundary via action_context_scope(). +# Use publish_action() directly only for shallow endpoint-level actions (VIEW, COMMENT, etc.). +# +# If you're adding a new caller to an instrumented function (e.g. GroupAssignee.objects.assign), +# wrap it with action_context_scope() so the action gets proper source attribution. + +MCP_USER_AGENT_PREFIX = "sentry-mcp/" +MCP_CLIENT_FAMILY_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_FAMILY" +SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" + +# Standardized client families the MCP buckets its callers into and forwards via +# X-Sentry-MCP-Client-Family (source of truth: client-family.ts in getsentry/sentry-mcp). +KNOWN_MCP_CLIENT_FAMILIES = frozenset( + {"claude-code", "cursor", "copilot", "opencode", "claude-desktop", "codex"} +) +MCP_CATCHALL_CLIENT_FAMILIES = frozenset({"other", "unknown"}) + + +class ActionSource(StrEnum): + WEB = "web" + SENTRY_CLI = "sentry-cli" + API = "api" + SYSTEM = "system" + MCP = "mcp" + SEER_EXPLORER = "seer:explorer" + SEER_SLACK = "seer:slack" + SLACK = "slack" + SLACK_STAGING = "slack_staging" + DISCORD = "discord" + MSTEAMS = "msteams" + GITHUB = "github" + GITHUB_ENTERPRISE = "github_enterprise" + GITLAB = "gitlab" + JIRA = "jira" + JIRA_SERVER = "jira_server" + AZURE_DEVOPS = "vsts" + BITBUCKET = "bitbucket" + BITBUCKET_SERVER = "bitbucket_server" + PAGERDUTY = "pagerduty" + OPSGENIE = "opsgenie" + PERFORCE = "perforce" + UNKNOWN = ( + "unknown" # fallback when ActionContext is missing; indicates a gap in instrumentation + ) + + +def resolve_action_source(request: Request) -> str: + """ + Determine the ActionSource from a request. Priority: MCP > Seer > frontend > CLI > API. + """ + user_agent = request.META.get("HTTP_USER_AGENT", "") + + if user_agent.startswith(MCP_USER_AGENT_PREFIX): + family = request.META.get(MCP_CLIENT_FAMILY_HEADER, "").strip().lower() + if family in KNOWN_MCP_CLIENT_FAMILIES: + return f"{ActionSource.MCP}:{family}" + if family and family not in MCP_CATCHALL_CLIENT_FAMILIES: + # Values outside this set are logged so we know to add new ones + logger.warning( + "issue.action_log.unrecognized_mcp_client_family", + extra={"client_family": family}, + ) + return ActionSource.MCP + + seer_referrer = request.META.get(SEER_REFERRER_HEADER, "") + if seer_referrer: + if "slack" in seer_referrer.lower(): + return ActionSource.SEER_SLACK + return ActionSource.SEER_EXPLORER + + from sentry.seer.endpoints.seer_rpc import SeerRpcSignatureAuthentication + + if isinstance( + getattr(request, "successful_authenticator", None), SeerRpcSignatureAuthentication + ): + return ActionSource.SEER_EXPLORER + + if is_frontend_request(request): + return ActionSource.WEB + + if user_agent.startswith("sentry-cli/"): + return ActionSource.SENTRY_CLI + + return ActionSource.API + + +class ActorType(StrEnum): + USER = "user" + SYSTEM = "system" + + +class ActionType(StrEnum): + VIEW = "view" + RESOLVE = "resolve" + UNRESOLVE = "unresolve" + ARCHIVE = "archive" + ASSIGN = "assign" + UNASSIGN = "unassign" + SET_PRIORITY = "set_priority" + MERGE_INTO_OTHER = "merge_into_other" + MERGE_FROM_OTHER = "merge_from_other" + DELETE = "delete" + BOOKMARK = "bookmark" + COMMENT = "comment" + COMMENT_EDIT = "comment_edit" + COMMENT_DELETE = "comment_delete" + SUBSCRIBE = "subscribe" + UNSUBSCRIBE = "unsubscribe" + MARK_REVIEWED = "mark_reviewed" + TRIGGER_AUTOFIX = "trigger_autofix" + + +@dataclass(frozen=True) +class ActionContext: + source: str + actor_id: int | None = None + + +_action_context: ContextVar[ActionContext | None] = ContextVar("action_context", default=None) + + +@contextmanager +def action_context_scope(source: str, actor_id: int | None) -> Generator[None]: + """ + Set action attribution context for the duration of a block. Must be set before + any code path that calls publish_action_from_context(). + """ + token = _action_context.set(ActionContext(source=source, actor_id=actor_id)) + try: + yield + finally: + _action_context.reset(token) + + +def get_action_context() -> ActionContext | None: + return _action_context.get() + + +def publish_action( + *, + action: ActionType, + source: str, + group_id: int, + organization_id: int, + project_id: int, + actor_id: int | None = None, + metadata: dict[str, Any] | None = None, + idempotency_key: str | None = None, +) -> None: + """ + Record an issue action directly. Use this for shallow endpoint-level actions + where the request is in scope (VIEW, COMMENT, TRIGGER_AUTOFIX). For mutation + sites deeper in the stack, prefer publish_action_from_context(). + """ + actor_type = ActorType.USER if actor_id is not None else ActorType.SYSTEM + metrics.incr("issue.action_log", tags={"action": action, "source": source}) + logger.info( + "issue.action_log", + extra={ + "action": action, + "source": source, + "group_id": group_id, + "organization_id": organization_id, + "project_id": project_id, + "actor_type": actor_type, + "actor_id": actor_id, + "metadata": metadata or {}, + "idempotency_key": idempotency_key, + }, + ) + + +def publish_action_from_context( + *, + action: ActionType, + group_id: int, + organization_id: int, + project_id: int, + metadata: dict[str, Any] | None = None, +) -> None: + """ + Record an issue action using the current ActionContext. This is the primary API + for mutation sites (assign, resolve, etc.) where the request is not in scope. + Requires action_context_scope() to have been set upstream. If context is missing, + logs an error to Sentry and records the action with source=UNKNOWN. + """ + ctx = get_action_context() + if ctx is None: + logger.error( + "publish_action_from_context called without ActionContext", + extra={"action": action, "group_id": group_id}, + ) + source: str = ActionSource.UNKNOWN + actor_id = None + else: + source = ctx.source + actor_id = ctx.actor_id + publish_action( + action=action, + source=source, + group_id=group_id, + organization_id=organization_id, + project_id=project_id, + actor_id=actor_id, + metadata=metadata, + ) diff --git a/src/sentry/issues/endpoints/group_details.py b/src/sentry/issues/endpoints/group_details.py index a5b65e7479a7..34b43825f0c4 100644 --- a/src/sentry/issues/endpoints/group_details.py +++ b/src/sentry/issues/endpoints/group_details.py @@ -40,6 +40,7 @@ from sentry.constants import CELL_API_DEPRECATION_DATE from sentry.integrations.api.serializers.models.external_issue import ExternalIssueSerializer from sentry.integrations.models.external_issue import ExternalIssue +from sentry.issues.action_log import ActionType, publish_action, resolve_action_source from sentry.issues.constants import get_issue_tsdb_group_model from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.issues.escalating.escalating_group_forecast import EscalatingGroupForecast @@ -319,6 +320,17 @@ def get(self, request: Request, group: Group) -> Response: data.update({"participants": participants}) + publish_action( + action=ActionType.VIEW, + source=resolve_action_source(request), + group_id=group.id, + organization_id=group.organization.id, + project_id=group.project_id, + actor_id=request.user.id + if getattr(request.user, "is_authenticated", False) + else None, + ) + metrics.incr( "group.get.http_response", sample_rate=1.0, diff --git a/src/sentry/issues/endpoints/group_notes.py b/src/sentry/issues/endpoints/group_notes.py index 442d02b046f8..94a9420c7e3e 100644 --- a/src/sentry/issues/endpoints/group_notes.py +++ b/src/sentry/issues/endpoints/group_notes.py @@ -13,6 +13,7 @@ from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework.group_notes import NoteSerializer from sentry.constants import CELL_API_DEPRECATION_DATE +from sentry.issues.action_log import ActionType, publish_action, resolve_action_source from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.activity import Activity from sentry.models.group import Group @@ -82,6 +83,16 @@ def post(self, request: Request, group: Group) -> Response: group=group, type=ActivityType.NOTE, user_id=request.user.id, data=data ) + publish_action( + action=ActionType.COMMENT, + source=resolve_action_source(request), + group_id=group.id, + organization_id=group.organization.id, + project_id=group.project_id, + actor_id=request.user.id, + metadata={"comment_id": activity.id}, + ) + self.create_external_comment(request, group, activity) webhook_data = { diff --git a/src/sentry/issues/endpoints/group_notes_details.py b/src/sentry/issues/endpoints/group_notes_details.py index 06605bb7f2cb..f77913a167f4 100644 --- a/src/sentry/issues/endpoints/group_notes_details.py +++ b/src/sentry/issues/endpoints/group_notes_details.py @@ -11,6 +11,7 @@ from sentry.api.serializers.rest_framework.group_notes import NoteSerializer from sentry.api.utils import to_valid_int_id from sentry.constants import CELL_API_DEPRECATION_DATE +from sentry.issues.action_log import ActionType, publish_action, resolve_action_source from sentry.issues.endpoints.bases.group import GroupEndpoint from sentry.models.activity import Activity from sentry.models.group import Group @@ -58,6 +59,16 @@ def delete(self, request: Request, group: Group, note_id: str) -> Response: note.delete() + publish_action( + action=ActionType.COMMENT_DELETE, + source=resolve_action_source(request), + group_id=group.id, + organization_id=group.organization.id, + project_id=group.project_id, + actor_id=request.user.id, + metadata={"comment_id": note_id_int}, + ) + comment_deleted.send_robust( project=group.project, user=request.user, @@ -102,6 +113,16 @@ def put(self, request: Request, group: Group, note_id: str) -> Response: note.data.update(dict(payload)) note.save() + publish_action( + action=ActionType.COMMENT_EDIT, + source=resolve_action_source(request), + group_id=group.id, + organization_id=group.organization.id, + project_id=group.project_id, + actor_id=request.user.id, + metadata={"comment_id": note.id}, + ) + if note.data.get("external_id"): self.update_external_comment(request, group, note) diff --git a/src/sentry/issues/ingest.py b/src/sentry/issues/ingest.py index 45d3541bbdd7..a0449b1a9390 100644 --- a/src/sentry/issues/ingest.py +++ b/src/sentry/issues/ingest.py @@ -22,6 +22,7 @@ save_grouphash_and_group, ) from sentry.incidents.grouptype import MetricIssue +from sentry.issues.action_log import ActionSource, action_context_scope from sentry.issues.grouptype import FeedbackGroup, should_create_group from sentry.issues.issue_occurrence import IssueOccurrence, IssueOccurrenceData from sentry.issues.priority import PriorityChangeReason, update_priority @@ -297,7 +298,8 @@ def save_issue_from_occurrence( try: # Since this calls hybrid cloud it has to be run outside the transaction assignee = occurrence.assignee.resolve() - GroupAssignee.objects.assign(group, assignee, create_only=True) + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): + GroupAssignee.objects.assign(group, assignee, create_only=True) except Exception: logger.exception("Failed process assignment for occurrence") @@ -334,15 +336,16 @@ def save_issue_from_occurrence( and group.priority != issue_kwargs["priority"] and group.priority_locked_at is None ): - update_priority( - group=group, - priority=PriorityLevel(issue_kwargs["priority"]), - sender="save_issue_from_occurrence", - reason=PriorityChangeReason.ISSUE_PLATFORM, - project=project, - is_regression=is_regression, - event_id=occurrence.event_id, - ) + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): + update_priority( + group=group, + priority=PriorityLevel(issue_kwargs["priority"]), + sender="save_issue_from_occurrence", + reason=PriorityChangeReason.ISSUE_PLATFORM, + project=project, + is_regression=is_regression, + event_id=occurrence.event_id, + ) open_period = get_latest_open_period(group) if open_period is not None: diff --git a/src/sentry/issues/priority.py b/src/sentry/issues/priority.py index 22facb85ccf2..14d2a104e7d6 100644 --- a/src/sentry/issues/priority.py +++ b/src/sentry/issues/priority.py @@ -4,6 +4,12 @@ from enum import StrEnum from typing import TYPE_CHECKING +from sentry.issues.action_log import ( + ActionSource, + ActionType, + action_context_scope, + publish_action_from_context, +) from sentry.models.activity import Activity from sentry.models.grouphistory import GroupHistoryStatus, record_group_history from sentry.models.project import Project @@ -73,6 +79,14 @@ def update_priority( record_group_history(group, status=PRIORITY_TO_GROUP_HISTORY_STATUS[priority], actor=actor) + publish_action_from_context( + action=ActionType.SET_PRIORITY, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + metadata={"priority": priority.to_str()}, + ) + # TODO (aci cleanup): if the group corresponds to a metric issue, then update its incident activity # we will remove this once we've fully deprecated the Incident model if group.type == MetricIssue.type_id: @@ -166,11 +180,12 @@ def auto_update_priority(group: Group, reason: PriorityChangeReason) -> None: new_priority = get_priority_for_ongoing_group(group) if new_priority is not None and new_priority != group.priority: - update_priority( - group=group, - priority=new_priority, - sender="auto_update_priority", - reason=reason, - actor=None, - project=group.project, - ) + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): + update_priority( + group=group, + priority=new_priority, + sender="auto_update_priority", + reason=reason, + actor=None, + project=group.project, + ) diff --git a/src/sentry/issues/status_change.py b/src/sentry/issues/status_change.py index fce7e200ddf1..65a155a51a68 100644 --- a/src/sentry/issues/status_change.py +++ b/src/sentry/issues/status_change.py @@ -10,6 +10,7 @@ from sentry import options from sentry.integrations.tasks.kick_off_status_syncs import kick_off_status_syncs +from sentry.issues.action_log import ActionType, publish_action_from_context from sentry.issues.ignored import IGNORED_CONDITION_FIELDS from sentry.issues.ongoing import TRANSITION_AFTER_DAYS from sentry.models.activity import Activity @@ -153,6 +154,14 @@ def handle_status_update( ) record_group_history_from_activity_type(group, activity_type, actor=acting_user) + action = ActionType.ARCHIVE if new_status == GroupStatus.IGNORED else ActionType.UNRESOLVE + publish_action_from_context( + action=action, + group_id=group.id, + organization_id=project_lookup[group.project_id].organization_id, + project_id=group.project_id, + ) + if update_open_period: update_group_open_period( group=group, diff --git a/src/sentry/models/groupassignee.py b/src/sentry/models/groupassignee.py index 69a27a3a9cab..a5e8f1a3adc8 100644 --- a/src/sentry/models/groupassignee.py +++ b/src/sentry/models/groupassignee.py @@ -13,6 +13,7 @@ from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.db.models.manager.base import BaseManager from sentry.integrations.services.assignment_source import AssignmentSource +from sentry.issues.action_log import ActionType, publish_action_from_context from sentry.models.grouphistory import GroupHistoryStatus, record_group_history from sentry.models.groupowner import GroupOwner from sentry.models.groupsubscription import GroupSubscription @@ -183,6 +184,13 @@ def assign( data=data, ) record_group_history(group, GroupHistoryStatus.ASSIGNED, actor=acting_user) + + publish_action_from_context( + action=ActionType.ASSIGN, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + ) GroupOwner.invalidate_assignee_exists_cache(group.id) metrics.incr("group.assignee.change", instance="assigned", skip_internal=True) @@ -225,6 +233,13 @@ def deassign( record_group_history(group, GroupHistoryStatus.UNASSIGNED, actor=acting_user) + publish_action_from_context( + action=ActionType.UNASSIGN, + group_id=group.id, + organization_id=group.project.organization_id, + project_id=group.project_id, + ) + # Clear ownership cache for the deassigned group ownership = ProjectOwnership.get_ownership_cached(group.project.id) if not ownership: diff --git a/src/sentry/models/groupinbox.py b/src/sentry/models/groupinbox.py index cf079590970f..3b914a0ec483 100644 --- a/src/sentry/models/groupinbox.py +++ b/src/sentry/models/groupinbox.py @@ -13,6 +13,7 @@ from sentry.db.models import FlexibleForeignKey, Model, cell_silo_model from sentry.db.models.fields.jsonfield import LegacyTextJSONField from sentry.db.models.manager.base_query_set import BaseQuerySet +from sentry.issues.action_log import ActionType, publish_action_from_context from sentry.models.activity import Activity from sentry.models.group import Group from sentry.models.grouphistory import ( @@ -102,6 +103,13 @@ def remove_group_from_inbox( user_id=user.id, ) record_group_history(group, GroupHistoryStatus.REVIEWED, actor=user) + + publish_action_from_context( + action=ActionType.MARK_REVIEWED, + group_id=group_inbox.group_id, + organization_id=group_inbox.group.project.organization_id, + project_id=group_inbox.group.project_id, + ) except GroupInbox.DoesNotExist: pass diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py index 6a775bdec1c8..2c930b30ffee 100644 --- a/src/sentry/models/projectownership.py +++ b/src/sentry/models/projectownership.py @@ -15,6 +15,7 @@ from sentry.backup.scopes import RelocationScope from sentry.db.models import Model, cell_silo_model, sane_repr from sentry.db.models.fields import FlexibleForeignKey +from sentry.issues.action_log import ActionSource, action_context_scope from sentry.issues.ownership.grammar import ( CODEOWNERS, Matcher, @@ -358,13 +359,14 @@ def handle_auto_assignment( ): return - assignment = GroupAssignee.objects.assign( - group, - owner, - create_only=not force_autoassign, - extra=activity_details, - force_autoassign=force_autoassign, - ) + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): + assignment = GroupAssignee.objects.assign( + group, + owner, + create_only=not force_autoassign, + extra=activity_details, + force_autoassign=force_autoassign, + ) if assignment["new_assignment"] or assignment["updated_assignment"]: try: diff --git a/src/sentry/receivers/releases.py b/src/sentry/receivers/releases.py index 04f1e1be1e55..51e5a6fb4151 100644 --- a/src/sentry/receivers/releases.py +++ b/src/sentry/receivers/releases.py @@ -7,6 +7,7 @@ from sentry import analytics, features from sentry.db.postgres.transactions import in_test_hide_transaction_boundary from sentry.integrations.analytics import IntegrationResolveCommitEvent, IntegrationResolvePREvent +from sentry.issues.action_log import ActionSource, action_context_scope from sentry.models.activity import Activity from sentry.models.commit import Commit from sentry.models.group import Group, GroupStatus @@ -150,9 +151,12 @@ def resolved_in_commit(instance: Commit, created, **kwargs): if acting_user: if self_assign_issue == "1" and not group.assignee_set.exists(): - GroupAssignee.objects.assign( - group=group, assigned_to=acting_user, acting_user=acting_user - ) + with action_context_scope( + source=ActionSource.SYSTEM, actor_id=acting_user.id + ): + GroupAssignee.objects.assign( + group=group, assigned_to=acting_user, acting_user=acting_user + ) # while we only create activity and assignment for one user we want to # subscribe every user @@ -261,9 +265,10 @@ def resolved_in_pull_request(instance: PullRequest, created, **kwargs): acting_user: RpcUser | None = None if user_list: acting_user = user_list[0] - GroupAssignee.objects.assign( - group=group, assigned_to=acting_user, acting_user=acting_user - ) + with action_context_scope(source=ActionSource.SYSTEM, actor_id=acting_user.id): + GroupAssignee.objects.assign( + group=group, assigned_to=acting_user, acting_user=acting_user + ) Activity.objects.create( project_id=group.project_id, diff --git a/src/sentry/seer/assisted_query/discover_tools.py b/src/sentry/seer/assisted_query/discover_tools.py index cf4ee6352e01..afe4b97c2fda 100644 --- a/src/sentry/seer/assisted_query/discover_tools.py +++ b/src/sentry/seer/assisted_query/discover_tools.py @@ -2,7 +2,7 @@ import re from typing import Any -from sentry.api import client +from sentry.api.client import ApiClient from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.models.apikey import ApiKey from sentry.models.organization import Organization @@ -93,7 +93,7 @@ def _get_tag_and_feature_flag_keys( # Get custom tags event_params = {**base_params, "dataset": Dataset.Events.value} - event_resp = client.get( + event_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{org_slug}/tags/", @@ -112,7 +112,7 @@ def _get_tag_and_feature_flag_keys( "dataset": Dataset.Events.value, "useFlagsBackend": "1", } - flags_resp = client.get( + flags_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{org_slug}/tags/", @@ -287,7 +287,7 @@ def get_event_filter_key_values( base_params["query"] = substring # Try tags query - resp = client.get( + resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/{filter_key}/values/", @@ -297,7 +297,7 @@ def get_event_filter_key_values( # Try ff query. In the case of collisions we use tag values as we deem them more important. # TODO: Pass in an explicit is_feature_flag param if the agent can produce it. if not resp.data: - resp = client.get( + resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/{filter_key}/values/", diff --git a/src/sentry/seer/assisted_query/issues_tools.py b/src/sentry/seer/assisted_query/issues_tools.py index bcd3411334cf..f8a01811b650 100644 --- a/src/sentry/seer/assisted_query/issues_tools.py +++ b/src/sentry/seer/assisted_query/issues_tools.py @@ -2,7 +2,7 @@ import re from typing import Any -from sentry.api import client +from sentry.api.client import ApiClient, ApiError from sentry.issues.grouptype import registry as group_type_registry from sentry.models.apikey import ApiKey from sentry.models.organization import Organization @@ -461,7 +461,7 @@ def get_issue_filter_keys( # Get event tags event_params = {**base_params, "dataset": Dataset.Events.value, "useCache": "1"} - event_resp = client.get( + event_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/", @@ -471,7 +471,7 @@ def get_issue_filter_keys( # Get issue tags (search_issues dataset) issue_params = {**base_params, "dataset": Dataset.IssuePlatform.value, "useCache": "1"} - issue_resp = client.get( + issue_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/", @@ -498,7 +498,7 @@ def get_issue_filter_keys( "useFlagsBackend": "1", "useCache": "1", } - flags_resp = client.get( + flags_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/", @@ -603,7 +603,7 @@ def get_filter_key_values( # 1. Try events dataset (regular tags) events_params = {**base_params, "dataset": Dataset.Events.value} - events_resp = client.get( + events_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/{attribute_key}/values/", @@ -614,7 +614,7 @@ def get_filter_key_values( # 2. Try search_issues dataset issues_params = {**base_params, "dataset": Dataset.IssuePlatform.value} - issues_resp = client.get( + issues_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/{attribute_key}/values/", @@ -626,7 +626,7 @@ def get_filter_key_values( # 3. Try flags backend (feature flags) only if no results were found. This is only enabled for events dataset if not all_results: flags_params = {**base_params, "dataset": Dataset.Events.value, "useFlagsBackend": "1"} - flags_resp = client.get( + flags_resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/tags/{attribute_key}/values/", @@ -711,14 +711,14 @@ def execute_issues_query( params["sort"] = sort try: - resp = client.get( + resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/issues/", params=params, ) return resp.data - except client.ApiError as e: + except ApiError as e: if e.status_code == 400: error_detail = e.body.get("detail") if isinstance(e.body, dict) else None return {"error": str(error_detail) if error_detail is not None else str(e.body)} @@ -777,7 +777,7 @@ def get_issues_stats( params["start"] = start params["end"] = end - resp = client.get( + resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/issues-stats/", diff --git a/src/sentry/seer/assisted_query/metrics_tools.py b/src/sentry/seer/assisted_query/metrics_tools.py index 2fa1b0faee07..cb8e36c438fa 100644 --- a/src/sentry/seer/assisted_query/metrics_tools.py +++ b/src/sentry/seer/assisted_query/metrics_tools.py @@ -1,7 +1,7 @@ import logging from typing import Any, TypedDict -from sentry.api import client +from sentry.api.client import ApiClient, ApiError from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.models.apikey import ApiKey from sentry.models.organization import Organization @@ -110,13 +110,13 @@ def get_metric_metadata( } try: - resp = client.get( + resp = ApiClient().get( auth=ApiKey(organization_id=organization.id, scope_list=API_KEY_SCOPES), user=None, path=f"/organizations/{organization.slug}/events/", params=params, ) - except client.ApiError as e: + except ApiError as e: # Surface status + body prefix in log extras so prod flakes are debuggable # without a new deploy. Keep the return `error` code stable for callers. logger.exception( diff --git a/src/sentry/seer/assisted_query/traces_tools.py b/src/sentry/seer/assisted_query/traces_tools.py index 2f14bac3b773..ae95615b2951 100644 --- a/src/sentry/seer/assisted_query/traces_tools.py +++ b/src/sentry/seer/assisted_query/traces_tools.py @@ -1,7 +1,7 @@ import logging from typing import Any -from sentry.api import client +from sentry.api.client import ApiClient from sentry.constants import ALL_ACCESS_PROJECT_ID from sentry.models.apikey import ApiKey from sentry.models.organization import Organization @@ -151,7 +151,7 @@ def get_attribute_names( query_params["end"] = end # API returns: [{"key": "...", "name": "span.op", "attributeSource": {...}}, ...] - resp = client.get( + resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/trace-items/attributes/", @@ -224,7 +224,7 @@ def get_attribute_values_with_substring( query_params["substringMatch"] = substring # API returns: [{"value": "ok", "count": 123, ...}, ...] - resp = client.get( + resp = ApiClient().get( auth=api_key, user=None, path=f"/organizations/{organization.slug}/trace-items/attributes/{field}/values/", diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py new file mode 100644 index 000000000000..e2ba3ae58ed1 --- /dev/null +++ b/tests/sentry/issues/test_action_log.py @@ -0,0 +1,353 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +import sentry.api.helpers.group_index.update +import sentry.issues.endpoints.group_details +import sentry.issues.priority +import sentry.issues.status_change +import sentry.models.groupassignee +import sentry.models.groupinbox +from sentry.issues.action_log import ( + ActionContext, + ActionType, + action_context_scope, + get_action_context, + publish_action, + resolve_action_source, +) +from sentry.models.group import GroupStatus +from sentry.seer.endpoints.seer_rpc import SeerRpcSignatureAuthentication +from sentry.testutils.cases import APITestCase, SnubaTestCase, TestCase +from sentry.types.group import GroupSubStatus, PriorityLevel + + +def _make_request( + *, + meta: dict[str, str] | None = None, + cookies: dict[str, str] | None = None, + auth: Any = None, + successful_authenticator: Any = None, +) -> MagicMock: + request = MagicMock() + request.META = meta or {} + request.COOKIES = cookies or {} + request.auth = auth if auth is not None else MagicMock() + request.successful_authenticator = successful_authenticator + return request + + +MCP_USER_AGENT = "sentry-mcp/0.18.0 (https://mcp.sentry.dev)" + + +class TestResolveActionSource(TestCase): + def test_mcp_known_family(self) -> None: + request = _make_request( + meta={ + "HTTP_USER_AGENT": MCP_USER_AGENT, + "HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "claude-code", + }, + ) + assert resolve_action_source(request) == "mcp:claude-code" + + def test_mcp_unrecognized_family_logs_and_falls_back(self) -> None: + request = _make_request( + meta={ + "HTTP_USER_AGENT": MCP_USER_AGENT, + "HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "some-new-editor", + }, + ) + with self.assertLogs("sentry.issues.action_log", level="WARNING") as logs: + assert resolve_action_source(request) == "mcp" + assert any(r.__dict__.get("client_family") == "some-new-editor" for r in logs.records) + + def test_mcp_catchall_family_is_not_logged(self) -> None: + request = _make_request( + meta={ + "HTTP_USER_AGENT": MCP_USER_AGENT, + "HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "unknown", + }, + ) + with self.assertNoLogs("sentry.issues.action_log", level="WARNING"): + assert resolve_action_source(request) == "mcp" + + def test_mcp_without_family(self) -> None: + request = _make_request(meta={"HTTP_USER_AGENT": MCP_USER_AGENT}) + assert resolve_action_source(request) == "mcp" + + def test_mcp_family_without_user_agent_is_not_mcp(self) -> None: + # The MCP source is gated on the User-Agent, not the client-family header, so the + # header alone does not flip the source to mcp. + request = _make_request(meta={"HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "claude-code"}) + assert resolve_action_source(request) == "api" + + def test_seer_referrer(self) -> None: + request = _make_request(meta={"HTTP_X_SEER_REFERRER": "seer-explorer"}) + assert resolve_action_source(request) == "seer:explorer" + + def test_seer_rpc_authenticator(self) -> None: + authenticator = MagicMock(spec=SeerRpcSignatureAuthentication) + request = _make_request(successful_authenticator=authenticator) + assert resolve_action_source(request) == "seer:explorer" + + def test_seer_referrer_takes_priority_over_rpc_auth(self) -> None: + authenticator = MagicMock(spec=SeerRpcSignatureAuthentication) + request = _make_request( + meta={"HTTP_X_SEER_REFERRER": "seer-slack"}, + successful_authenticator=authenticator, + ) + assert resolve_action_source(request) == "seer:slack" + + def test_frontend_request(self) -> None: + request = _make_request(cookies={"session": "abc"}) + request.auth = None + assert resolve_action_source(request) == "web" + + def test_sentry_cli(self) -> None: + request = _make_request(meta={"HTTP_USER_AGENT": "sentry-cli/2.30.0"}) + assert resolve_action_source(request) == "sentry-cli" + + def test_generic_api_fallback(self) -> None: + request = _make_request(meta={"HTTP_USER_AGENT": "python-requests/2.31.0"}) + assert resolve_action_source(request) == "api" + + +class TestActionContext(TestCase): + def test_default_is_none(self) -> None: + assert get_action_context() is None + + def test_scope_sets_and_resets(self) -> None: + with action_context_scope(source="web", actor_id=42): + ctx = get_action_context() + assert ctx is not None + assert ctx.source == "web" + assert ctx.actor_id == 42 + assert get_action_context() is None + + def test_scope_without_actor(self) -> None: + with action_context_scope(source="system", actor_id=None): + ctx = get_action_context() + assert ctx is not None + assert ctx.source == "system" + assert ctx.actor_id is None + + def test_nested_scopes(self) -> None: + with action_context_scope(source="web", actor_id=1): + with action_context_scope(source="api", actor_id=2): + ctx = get_action_context() + assert ctx == ActionContext(source="api", actor_id=2) + ctx = get_action_context() + assert ctx == ActionContext(source="web", actor_id=1) + + +class TestPublishAction(TestCase): + def test_emits_structured_log(self) -> None: + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.RESOLVE, + source="mcp:claude-code", + group_id=1, + organization_id=2, + project_id=3, + actor_id=4, + ) + assert len(logs.records) == 1 + record = logs.records[0] + extra = record.__dict__ + assert record.message == "issue.action_log" + assert extra["action"] == "resolve" + assert extra["source"] == "mcp:claude-code" + assert extra["actor_id"] == 4 + + def test_actor_type_derived_from_actor_id(self) -> None: + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.RESOLVE, + source="web", + group_id=1, + organization_id=2, + project_id=3, + actor_id=99, + ) + assert logs.records[0].__dict__["actor_type"] == "user" + + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action( + action=ActionType.RESOLVE, + source="system", + group_id=1, + organization_id=2, + project_id=3, + ) + assert logs.records[0].__dict__["actor_type"] == "system" + + +class TestPublishActionFromContext(TestCase): + def test_logs_error_and_uses_unknown_without_context(self) -> None: + from sentry.issues.action_log import publish_action_from_context + + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + publish_action_from_context( + action=ActionType.RESOLVE, + group_id=1, + organization_id=2, + project_id=3, + ) + error_records = [r for r in logs.records if r.levelname == "ERROR"] + assert any("without ActionContext" in r.message for r in error_records) + info_record = [r for r in logs.records if r.message == "issue.action_log"][0] + assert info_record.__dict__["source"] == "unknown" + + +class TestActionLogIntegration(APITestCase, SnubaTestCase): + def setUp(self) -> None: + super().setUp() + self.login_as(user=self.user) + self.group = self.create_group( + status=GroupStatus.UNRESOLVED, + substatus=GroupSubStatus.ONGOING, + priority=PriorityLevel.MEDIUM, + ) + self.url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group.id}/" + + @patch.object( + sentry.api.helpers.group_index.update, "publish_action_from_context", autospec=True + ) + def test_resolve_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"status": "resolved"}, format="json") + assert response.status_code == 200 + resolve_calls = [ + c for c in mock_publish.call_args_list if c.kwargs.get("action") == ActionType.RESOLVE + ] + assert len(resolve_calls) == 1 + assert resolve_calls[0].kwargs["group_id"] == self.group.id + + @patch.object( + sentry.api.helpers.group_index.update, "publish_action_from_context", autospec=True + ) + def test_resolve_already_resolved_skips(self, mock_publish: MagicMock) -> None: + self.group.update(status=GroupStatus.RESOLVED, substatus=None) + response = self.client.put(self.url, data={"status": "resolved"}, format="json") + assert response.status_code == 200 + resolve_calls = [ + c for c in mock_publish.call_args_list if c.kwargs.get("action") == ActionType.RESOLVE + ] + assert len(resolve_calls) == 0 + + @patch.object(sentry.issues.status_change, "publish_action_from_context", autospec=True) + def test_archive_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put( + self.url, + data={"status": "ignored", "substatus": "archived_until_escalating"}, + format="json", + ) + assert response.status_code == 200 + mock_publish.assert_called_once() + assert mock_publish.call_args.kwargs["action"] == ActionType.ARCHIVE + + @patch.object(sentry.issues.status_change, "publish_action_from_context", autospec=True) + def test_archive_already_archived_skips(self, mock_publish: MagicMock) -> None: + self.group.update(status=GroupStatus.IGNORED, substatus=GroupSubStatus.UNTIL_ESCALATING) + response = self.client.put( + self.url, + data={"status": "ignored", "substatus": "archived_until_escalating"}, + format="json", + ) + assert response.status_code == 200 + mock_publish.assert_not_called() + + @patch.object(sentry.issues.priority, "publish_action_from_context", autospec=True) + def test_priority_change_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"priority": "high"}, format="json") + assert response.status_code == 200 + mock_publish.assert_called_once() + assert mock_publish.call_args.kwargs["action"] == ActionType.SET_PRIORITY + + @patch.object(sentry.issues.priority, "publish_action_from_context", autospec=True) + def test_priority_same_value_skips(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"priority": "medium"}, format="json") + assert response.status_code == 200 + mock_publish.assert_not_called() + + @patch.object(sentry.models.groupassignee, "publish_action_from_context", autospec=True) + def test_assign_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.put( + self.url, data={"assignedTo": f"user:{self.user.id}"}, format="json" + ) + assert response.status_code == 200 + mock_publish.assert_called_once() + assert mock_publish.call_args.kwargs["action"] == ActionType.ASSIGN + + @patch.object(sentry.models.groupassignee, "publish_action_from_context", autospec=True) + def test_assign_same_user_skips(self, mock_publish: MagicMock) -> None: + self.client.put(self.url, data={"assignedTo": f"user:{self.user.id}"}, format="json") + mock_publish.reset_mock() + self.client.put(self.url, data={"assignedTo": f"user:{self.user.id}"}, format="json") + mock_publish.assert_not_called() + + @patch.object(sentry.models.groupassignee, "publish_action_from_context", autospec=True) + def test_unassign_emits_action(self, mock_publish: MagicMock) -> None: + self.client.put(self.url, data={"assignedTo": f"user:{self.user.id}"}, format="json") + mock_publish.reset_mock() + response = self.client.put(self.url, data={"assignedTo": ""}, format="json") + assert response.status_code == 200 + unassign_calls = [ + c for c in mock_publish.call_args_list if c.kwargs.get("action") == ActionType.UNASSIGN + ] + assert len(unassign_calls) == 1 + + @patch.object(sentry.models.groupassignee, "publish_action_from_context", autospec=True) + def test_unassign_without_assignee_skips(self, mock_publish: MagicMock) -> None: + response = self.client.put(self.url, data={"assignedTo": ""}, format="json") + assert response.status_code == 200 + mock_publish.assert_not_called() + + @patch.object(sentry.issues.endpoints.group_details, "publish_action", autospec=True) + def test_view_emits_action(self, mock_publish: MagicMock) -> None: + response = self.client.get(self.url, format="json") + assert response.status_code == 200 + mock_publish.assert_called_once() + assert mock_publish.call_args.kwargs["action"] == ActionType.VIEW + + @patch.object(sentry.models.groupinbox, "publish_action_from_context", autospec=True) + def test_mark_reviewed_emits_for_inbox_groups(self, mock_publish: MagicMock) -> None: + from sentry.models.groupinbox import GroupInbox, GroupInboxReason, add_group_to_inbox + + group_in_inbox = self.create_group( + status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.NEW + ) + group_not_in_inbox = self.create_group( + status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING + ) + add_group_to_inbox(group_in_inbox, GroupInboxReason.NEW) + assert GroupInbox.objects.filter(group=group_in_inbox).exists() + assert not GroupInbox.objects.filter(group=group_not_in_inbox).exists() + + url = f"/api/0/organizations/{self.organization.slug}/issues/?id={group_in_inbox.id}&id={group_not_in_inbox.id}" + response = self.client.put(url, data={"inbox": False}, format="json") + assert response.status_code == 200 + reviewed_calls = [ + c + for c in mock_publish.call_args_list + if c.kwargs.get("action") == ActionType.MARK_REVIEWED + ] + assert len(reviewed_calls) == 1 + assert reviewed_calls[0].kwargs["group_id"] == group_in_inbox.id + + @patch.object(sentry.api.helpers.group_index.update, "publish_action", autospec=True) + def test_merge_emits_actions(self, mock_publish: MagicMock) -> None: + group2 = self.create_group(status=GroupStatus.UNRESOLVED, substatus=GroupSubStatus.ONGOING) + url = f"/api/0/organizations/{self.organization.slug}/issues/?id={self.group.id}&id={group2.id}" + response = self.client.put(url, data={"merge": 1}, format="json") + assert response.status_code == 200 + merge_from = [ + c + for c in mock_publish.call_args_list + if c.kwargs.get("action") == ActionType.MERGE_FROM_OTHER + ] + merge_into = [ + c + for c in mock_publish.call_args_list + if c.kwargs.get("action") == ActionType.MERGE_INTO_OTHER + ] + assert len(merge_from) == 1 + assert len(merge_into) == 1 diff --git a/tests/sentry/receivers/test_releases.py b/tests/sentry/receivers/test_releases.py index 0fefcdd6abe9..d3d1921060f3 100644 --- a/tests/sentry/receivers/test_releases.py +++ b/tests/sentry/receivers/test_releases.py @@ -224,18 +224,26 @@ def test_matching_author_with_assignment(self) -> None: ) author.preload_users() - commit = Commit.objects.create( - key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), - organization_id=group.organization.id, - repository_id=repo.id, - message=f"Foo Biz\n\nFixes {group.qualified_short_id}", - author=author, - ) + with self.assertLogs("sentry.issues.action_log", level="INFO") as logs: + commit = Commit.objects.create( + key=sha1(uuid4().hex.encode("utf-8")).hexdigest(), + organization_id=group.organization.id, + repository_id=repo.id, + message=f"Foo Biz\n\nFixes {group.qualified_short_id}", + author=author, + ) self.assertLinkedFromCommitDeferred(group, commit) assert GroupAssignee.objects.filter(group=group, user_id=user.id).exists() + # The self-assign is attributed to the commit author, not logged as a system action. + assign_records = [r for r in logs.records if r.__dict__.get("action") == "assign"] + assert len(assign_records) == 1 + assert assign_records[0].__dict__["actor_id"] == user.id + assert assign_records[0].__dict__["actor_type"] == "user" + assert assign_records[0].__dict__["source"] == "system" + assert Activity.objects.filter( project=group.project, group=group, type=ActivityType.ASSIGNED.value, user_id=user.id )[0].data == { diff --git a/tests/sentry/seer/assisted_query/test_metrics_tools.py b/tests/sentry/seer/assisted_query/test_metrics_tools.py index 521c34e5534a..cde2f0191da7 100644 --- a/tests/sentry/seer/assisted_query/test_metrics_tools.py +++ b/tests/sentry/seer/assisted_query/test_metrics_tools.py @@ -1,6 +1,6 @@ from unittest.mock import MagicMock, patch -from sentry.api import client +from sentry.api.client import ApiError from sentry.seer.assisted_query.metrics_tools import ( _build_or_query, get_metric_metadata, @@ -36,8 +36,9 @@ def setUp(self) -> None: self.org = self.create_organization() self.project = self.create_project(organization=self.org) - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_returns_distinct_tuples_with_count(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_returns_distinct_tuples_with_count(self, mock_client_cls: MagicMock) -> None: + mock_client = mock_client_cls.return_value response = MagicMock() response.data = { "data": [ @@ -88,8 +89,9 @@ def test_returns_distinct_tuples_with_count(self, mock_client: MagicMock) -> Non # over-fetch by 1 to detect has_more assert params["per_page"] == 11 - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_empty_substrings_short_circuits(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_empty_substrings_short_circuits(self, mock_client_cls: MagicMock) -> None: + mock_client = mock_client_cls.return_value result = get_metric_metadata( org_id=self.org.id, project_ids=[self.project.id], @@ -98,8 +100,9 @@ def test_empty_substrings_short_circuits(self, mock_client: MagicMock) -> None: assert result == {"candidates": [], "has_more": False} mock_client.get.assert_not_called() - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_has_more_when_result_exceeds_limit(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_has_more_when_result_exceeds_limit(self, mock_client_cls: MagicMock) -> None: + mock_client = mock_client_cls.return_value # Asking for limit=2 means we over-fetch 3. If we actually see 3, has_more=True. response = MagicMock() response.data = { @@ -124,8 +127,11 @@ def test_has_more_when_result_exceeds_limit(self, mock_client: MagicMock) -> Non assert result["has_more"] is True assert len(result["candidates"]) == 2 - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_has_more_uses_raw_row_count_not_filtered_count(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_has_more_uses_raw_row_count_not_filtered_count( + self, mock_client_cls: MagicMock + ) -> None: + mock_client = mock_client_cls.return_value """Regression: has_more must be computed from what Sentry returned, not from what survived our local parse filter. We over-fetch by 1 specifically to detect \"Sentry has more matches than you asked for\"; filtering a @@ -164,8 +170,9 @@ def test_has_more_uses_raw_row_count_not_filtered_count(self, mock_client: Magic assert result["has_more"] is True assert len(result["candidates"]) == 2 - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_skips_rows_missing_name_or_type(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_skips_rows_missing_name_or_type(self, mock_client_cls: MagicMock) -> None: + mock_client = mock_client_cls.return_value response = MagicMock() response.data = { "data": [ @@ -189,8 +196,9 @@ def test_skips_rows_missing_name_or_type(self, mock_client: MagicMock) -> None: assert len(result["candidates"]) == 1 assert result["candidates"][0]["name"] == "good" - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_missing_unit_defaults_to_none(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_missing_unit_defaults_to_none(self, mock_client_cls: MagicMock) -> None: + mock_client = mock_client_cls.return_value response = MagicMock() response.data = { "data": [ @@ -211,8 +219,9 @@ def test_missing_unit_defaults_to_none(self, mock_client: MagicMock) -> None: ) assert result["candidates"][0]["unit"] == "none" - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_organization_not_found_returns_error(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_organization_not_found_returns_error(self, mock_client_cls: MagicMock) -> None: + mock_client = mock_client_cls.return_value """Missing organization must surface as an explicit error code, not a silent empty result. Seer translates the error key into success=False so Langfuse traces distinguish real failures from sparse metric catalogs.""" @@ -231,12 +240,12 @@ def test_organization_not_found_returns_error(self, mock_client: MagicMock) -> N # No events query should be attempted. mock_client.get.assert_not_called() - @patch("sentry.seer.assisted_query.metrics_tools.client") - def test_events_query_failure_returns_error(self, mock_client: MagicMock) -> None: + @patch("sentry.seer.assisted_query.metrics_tools.ApiClient") + def test_events_query_failure_returns_error(self, mock_client_cls: MagicMock) -> None: """When the underlying events API raises ApiError, return an explicit error code rather than a silent empty result.""" - mock_client.ApiError = client.ApiError - mock_client.get.side_effect = client.ApiError(500, "snuba exploded") + mock_client = mock_client_cls.return_value + mock_client.get.side_effect = ApiError(500, "snuba exploded") result = get_metric_metadata( org_id=self.org.id,