From 483d1277e53413fd94ac0a56ecd9d53221679486 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Wed, 27 May 2026 14:40:21 -0700 Subject: [PATCH 01/19] feat(issues): Add Issue Action Log instrumentation for issue mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `publish_action` calls alongside existing Activity creation sites to log issue mutations with source attribution (web, API, MCP, Seer). Uses a contextvar (`ActionContext`) set at the API boundary in `update_groups()` and read at lower-level mutation sites, so functions like `GroupAssignee.assign()` and `update_priority()` can log actions without signature changes. Instrumented actions: resolve, unresolve, archive, assign, unassign, set_priority, merge, mark_reviewed, view, trigger_autofix, comment (create/edit/delete). Currently a no-op structured logger — will be wired to the real GroupActionLogEntry model once that lands. --- src/sentry/api/helpers/group_index/update.py | 139 +++++--- src/sentry/issues/action_log.py | 199 +++++++++++ src/sentry/issues/endpoints/group_details.py | 12 + src/sentry/issues/endpoints/group_notes.py | 11 + .../issues/endpoints/group_notes_details.py | 21 ++ src/sentry/issues/priority.py | 9 + src/sentry/issues/status_change.py | 9 + src/sentry/models/groupassignee.py | 15 + src/sentry/models/groupinbox.py | 8 + src/sentry/seer/endpoints/group_ai_autofix.py | 18 + tests/sentry/issues/test_action_log.py | 312 ++++++++++++++++++ 11 files changed, 709 insertions(+), 44 deletions(-) create mode 100644 src/sentry/issues/action_log.py create mode 100644 tests/sentry/issues/test_action_log.py 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/issues/action_log.py b/src/sentry/issues/action_log.py new file mode 100644 index 000000000000..6c1ce53db05d --- /dev/null +++ b/src/sentry/issues/action_log.py @@ -0,0 +1,199 @@ +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 + +logger = logging.getLogger(__name__) + +MCP_CLIENT_NAME_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_NAME" +SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" + +_MCP_APPLICATION_ID_NOT_CHECKED = -1 +MCP_APPLICATION_ID: int = _MCP_APPLICATION_ID_NOT_CHECKED + +KNOWN_MCP_CLIENTS: dict[str, str] = { + "claude code": "claude-code", + "claude-code": "claude-code", + "cursor": "cursor", + "copilot": "copilot", + "windsurf": "windsurf", +} + + +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" + DISCORD = "discord" + MSTEAMS = "msteams" + GITHUB = "github" + GITLAB = "gitlab" + JIRA = "jira" + + +def _get_mcp_application_id() -> int | None: + global MCP_APPLICATION_ID + if MCP_APPLICATION_ID != _MCP_APPLICATION_ID_NOT_CHECKED: + return MCP_APPLICATION_ID if MCP_APPLICATION_ID > 0 else None + + from sentry.models.apiapplication import ApiApplication + + try: + app = ApiApplication.objects.filter(name__icontains="sentry-mcp").first() + if app: + MCP_APPLICATION_ID = app.id + return MCP_APPLICATION_ID + except Exception: + logger.exception("Failed to look up MCP application ID") + + MCP_APPLICATION_ID = 0 + return None + + +def resolve_action_source(request: Request | None) -> str: + if request is None: + return ActionSource.SYSTEM + + application_id = getattr(request.auth, "application_id", None) + mcp_app_id = _get_mcp_application_id() + if mcp_app_id and application_id == mcp_app_id: + client_name = request.META.get(MCP_CLIENT_NAME_HEADER, "") + normalized = client_name.strip().lower() + slug = KNOWN_MCP_CLIENTS.get(normalized) + if slug: + return f"mcp:{slug}" + 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 + + user_agent = request.META.get("HTTP_USER_AGENT", "") + 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 = None) -> Generator[None]: + 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: + actor_type = ActorType.USER if actor_id is not None else ActorType.SYSTEM + 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: + ctx = get_action_context() + if ctx is None: + return + publish_action( + action=action, + source=ctx.source, + group_id=group_id, + organization_id=organization_id, + project_id=project_id, + actor_id=ctx.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/priority.py b/src/sentry/issues/priority.py index 22facb85ccf2..419d3f42951e 100644 --- a/src/sentry/issues/priority.py +++ b/src/sentry/issues/priority.py @@ -4,6 +4,7 @@ from enum import StrEnum from typing import TYPE_CHECKING +from sentry.issues.action_log import ActionType, 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 +74,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: 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/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index f2cba8afd9b6..f61ab3af5733 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -24,6 +24,7 @@ from sentry.apidocs.parameters import GlobalParams, IssueParams from sentry.apidocs.utils import inline_sentry_response_serializer 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 GroupAiEndpoint from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig @@ -181,6 +182,23 @@ def post(self, request: Request, group: Group) -> Response: The process runs asynchronously, and you can get the state using the GET endpoint. """ + response = self._post_inner(request, group) + + if response.status_code == status.HTTP_202_ACCEPTED: + publish_action( + action=ActionType.TRIGGER_AUTOFIX, + 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, + ) + + return response + + def _post_inner(self, request: Request, group: Group) -> Response: serializer = ExplorerAutofixRequestSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py new file mode 100644 index 000000000000..fd98b7238c5a --- /dev/null +++ b/tests/sentry/issues/test_action_log.py @@ -0,0 +1,312 @@ +from typing import Any +from unittest.mock import MagicMock, patch + +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( + *, + application_id: int | None = None, + 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 {} + + if auth is not None: + request.auth = auth + else: + token = MagicMock() + token.application_id = application_id + request.auth = token + + request.successful_authenticator = successful_authenticator + return request + + +class TestResolveActionSource(TestCase): + def test_no_request_returns_system(self) -> None: + assert resolve_action_source(None) == "system" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_known_client(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=42, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code"}, + ) + assert resolve_action_source(request) == "mcp:claude-code" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_unknown_client(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=42, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "some-new-editor"}, + ) + assert resolve_action_source(request) == "mcp" + + @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) + def test_mcp_header_ignored_when_wrong_application(self, mock_get_id: MagicMock) -> None: + request = _make_request( + application_id=999, + meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "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_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_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" + + +PUBLISH_UPDATE = "sentry.api.helpers.group_index.update.publish_action" +PUBLISH_UPDATE_CTX = "sentry.api.helpers.group_index.update.publish_action_from_context" +PUBLISH_DETAILS = "sentry.issues.endpoints.group_details.publish_action" +PUBLISH_STATUS_CTX = "sentry.issues.status_change.publish_action_from_context" +PUBLISH_ASSIGN_CTX = "sentry.models.groupassignee.publish_action_from_context" +PUBLISH_PRIORITY_CTX = "sentry.issues.priority.publish_action_from_context" +PUBLISH_INBOX_CTX = "sentry.models.groupinbox.publish_action_from_context" + + +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(PUBLISH_UPDATE_CTX) + 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(PUBLISH_UPDATE_CTX) + 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(PUBLISH_STATUS_CTX) + 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(PUBLISH_STATUS_CTX) + 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(PUBLISH_PRIORITY_CTX) + 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(PUBLISH_PRIORITY_CTX) + 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(PUBLISH_ASSIGN_CTX) + 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(PUBLISH_ASSIGN_CTX) + 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(PUBLISH_ASSIGN_CTX) + 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(PUBLISH_ASSIGN_CTX) + 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(PUBLISH_DETAILS) + 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(PUBLISH_INBOX_CTX) + 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(PUBLISH_UPDATE) + 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 From fe724a40f6cd121655c6f44a3dd5c733aa742687 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Wed, 27 May 2026 15:18:27 -0700 Subject: [PATCH 02/19] fix(typing): Use ApiClient() instead of ambiguous client module import The `from sentry.api import client` resolves to the module, not the ApiClient instance, causing mypy attr-defined errors on `.get()` calls. --- .../seer/assisted_query/discover_tools.py | 10 +++++----- .../seer/assisted_query/issues_tools.py | 20 +++++++++---------- .../seer/assisted_query/metrics_tools.py | 6 +++--- .../seer/assisted_query/traces_tools.py | 6 +++--- 4 files changed, 21 insertions(+), 21 deletions(-) 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/", From 33847a712e247580ebe868302d0e34aa112ad8df Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Wed, 27 May 2026 15:32:02 -0700 Subject: [PATCH 03/19] fix(tests): Update metrics_tools tests for ApiClient import change --- .../seer/assisted_query/test_metrics_tools.py | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) 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, From e3e6df1cc3a1ab582d4ddc4ecb390ca625d64455 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 09:56:23 -0700 Subject: [PATCH 04/19] fix(issues): Use django cache for MCP app ID, distinguish unarchive from unresolve - Replace module-level global MCP_APPLICATION_ID with django.core.cache (5min TTL), so a missing app record on first lookup doesn't permanently poison the cache for the worker lifetime - Add ActionType.UNARCHIVE to distinguish un-ignoring (IGNORED->UNRESOLVED) from un-resolving (RESOLVED->UNRESOLVED), matching the existing signal distinction (issue_unignored vs issue_unresolved) --- src/sentry/issues/action_log.py | 22 ++++++++++++---------- src/sentry/issues/status_change.py | 8 +++++++- tests/sentry/issues/test_action_log.py | 8 ++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index 6c1ce53db05d..d11351e52f28 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -17,8 +17,8 @@ MCP_CLIENT_NAME_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_NAME" SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" -_MCP_APPLICATION_ID_NOT_CHECKED = -1 -MCP_APPLICATION_ID: int = _MCP_APPLICATION_ID_NOT_CHECKED +MCP_APP_ID_CACHE_KEY = "action_log.mcp_application_id" +MCP_APP_ID_CACHE_TTL = 300 KNOWN_MCP_CLIENTS: dict[str, str] = { "claude code": "claude-code", @@ -46,22 +46,23 @@ class ActionSource(StrEnum): def _get_mcp_application_id() -> int | None: - global MCP_APPLICATION_ID - if MCP_APPLICATION_ID != _MCP_APPLICATION_ID_NOT_CHECKED: - return MCP_APPLICATION_ID if MCP_APPLICATION_ID > 0 else None + from django.core.cache import cache + + cached = cache.get(MCP_APP_ID_CACHE_KEY) + if cached is not None: + return cached if cached > 0 else None from sentry.models.apiapplication import ApiApplication try: app = ApiApplication.objects.filter(name__icontains="sentry-mcp").first() - if app: - MCP_APPLICATION_ID = app.id - return MCP_APPLICATION_ID + app_id = app.id if app else 0 except Exception: logger.exception("Failed to look up MCP application ID") + return None - MCP_APPLICATION_ID = 0 - return None + cache.set(MCP_APP_ID_CACHE_KEY, app_id, MCP_APP_ID_CACHE_TTL) + return app_id if app_id > 0 else None def resolve_action_source(request: Request | None) -> str: @@ -110,6 +111,7 @@ class ActionType(StrEnum): VIEW = "view" RESOLVE = "resolve" UNRESOLVE = "unresolve" + UNARCHIVE = "unarchive" ARCHIVE = "archive" ASSIGN = "assign" UNASSIGN = "unassign" diff --git a/src/sentry/issues/status_change.py b/src/sentry/issues/status_change.py index 65a155a51a68..7c6cbffd3650 100644 --- a/src/sentry/issues/status_change.py +++ b/src/sentry/issues/status_change.py @@ -142,6 +142,7 @@ def handle_status_update( ) for group in group_list: + old_status = group.status group.status = new_status group.substatus = new_substatus @@ -154,7 +155,12 @@ 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 + if new_status == GroupStatus.IGNORED: + action = ActionType.ARCHIVE + elif old_status == GroupStatus.IGNORED: + action = ActionType.UNARCHIVE + else: + action = ActionType.UNRESOLVE publish_action_from_context( action=action, group_id=group.id, diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index fd98b7238c5a..a5ac6676930f 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -214,6 +214,14 @@ def test_archive_already_archived_skips(self, mock_publish: MagicMock) -> None: assert response.status_code == 200 mock_publish.assert_not_called() + @patch(PUBLISH_STATUS_CTX) + def test_unarchive_emits_unarchive_not_unresolve(self, mock_publish: MagicMock) -> None: + self.group.update(status=GroupStatus.IGNORED, substatus=GroupSubStatus.UNTIL_ESCALATING) + response = self.client.put(self.url, data={"status": "unresolved"}, format="json") + assert response.status_code == 200 + mock_publish.assert_called_once() + assert mock_publish.call_args.kwargs["action"] == ActionType.UNARCHIVE + @patch(PUBLISH_PRIORITY_CTX) def test_priority_change_emits_action(self, mock_publish: MagicMock) -> None: response = self.client.put(self.url, data={"priority": "high"}, format="json") From 131c6280c322b6181ddd4ce3843d9c1dfbe1961c Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 10:27:21 -0700 Subject: [PATCH 05/19] fix(issues): Revert UNARCHIVE action type, skip MCP lookup when no application_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove UNARCHIVE — "unresolve" is the destination state regardless of whether the issue was previously resolved or archived - Skip _get_mcp_application_id() cache/DB call when request has no application_id (the common case for non-token-auth requests) - Use ActionSource.MCP constant in f-string for MCP client slug --- src/sentry/issues/action_log.py | 5 ++--- src/sentry/issues/status_change.py | 8 +------- tests/sentry/issues/test_action_log.py | 8 -------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index d11351e52f28..679ff1394dcb 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -70,13 +70,13 @@ def resolve_action_source(request: Request | None) -> str: return ActionSource.SYSTEM application_id = getattr(request.auth, "application_id", None) - mcp_app_id = _get_mcp_application_id() + mcp_app_id = _get_mcp_application_id() if application_id is not None else None if mcp_app_id and application_id == mcp_app_id: client_name = request.META.get(MCP_CLIENT_NAME_HEADER, "") normalized = client_name.strip().lower() slug = KNOWN_MCP_CLIENTS.get(normalized) if slug: - return f"mcp:{slug}" + return f"{ActionSource.MCP}:{slug}" return ActionSource.MCP seer_referrer = request.META.get(SEER_REFERRER_HEADER, "") @@ -111,7 +111,6 @@ class ActionType(StrEnum): VIEW = "view" RESOLVE = "resolve" UNRESOLVE = "unresolve" - UNARCHIVE = "unarchive" ARCHIVE = "archive" ASSIGN = "assign" UNASSIGN = "unassign" diff --git a/src/sentry/issues/status_change.py b/src/sentry/issues/status_change.py index 7c6cbffd3650..65a155a51a68 100644 --- a/src/sentry/issues/status_change.py +++ b/src/sentry/issues/status_change.py @@ -142,7 +142,6 @@ def handle_status_update( ) for group in group_list: - old_status = group.status group.status = new_status group.substatus = new_substatus @@ -155,12 +154,7 @@ def handle_status_update( ) record_group_history_from_activity_type(group, activity_type, actor=acting_user) - if new_status == GroupStatus.IGNORED: - action = ActionType.ARCHIVE - elif old_status == GroupStatus.IGNORED: - action = ActionType.UNARCHIVE - else: - action = ActionType.UNRESOLVE + action = ActionType.ARCHIVE if new_status == GroupStatus.IGNORED else ActionType.UNRESOLVE publish_action_from_context( action=action, group_id=group.id, diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index a5ac6676930f..fd98b7238c5a 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -214,14 +214,6 @@ def test_archive_already_archived_skips(self, mock_publish: MagicMock) -> None: assert response.status_code == 200 mock_publish.assert_not_called() - @patch(PUBLISH_STATUS_CTX) - def test_unarchive_emits_unarchive_not_unresolve(self, mock_publish: MagicMock) -> None: - self.group.update(status=GroupStatus.IGNORED, substatus=GroupSubStatus.UNTIL_ESCALATING) - response = self.client.put(self.url, data={"status": "unresolved"}, format="json") - assert response.status_code == 200 - mock_publish.assert_called_once() - assert mock_publish.call_args.kwargs["action"] == ActionType.UNARCHIVE - @patch(PUBLISH_PRIORITY_CTX) def test_priority_change_emits_action(self, mock_publish: MagicMock) -> None: response = self.client.put(self.url, data={"priority": "high"}, format="json") From 09c707ad131a453e6032d44247c264020bae33e1 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 11:20:35 -0700 Subject: [PATCH 06/19] fix(issues): Log unknown source instead of dropping actions without context When publish_action_from_context is called without an ActionContext, log an error (creates a Sentry issue with stack trace) and record the action with source="unknown" instead of silently dropping it. --- src/sentry/issues/action_log.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index 679ff1394dcb..33a1f5d084ca 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -188,13 +188,21 @@ def publish_action_from_context( ) -> None: ctx = get_action_context() if ctx is None: - return + logger.error( + "publish_action_from_context called without ActionContext", + extra={"action": action, "group_id": group_id}, + ) + source = "unknown" + actor_id = None + else: + source = ctx.source + actor_id = ctx.actor_id publish_action( action=action, - source=ctx.source, + source=source, group_id=group_id, organization_id=organization_id, project_id=project_id, - actor_id=ctx.actor_id, + actor_id=actor_id, metadata=metadata, ) From 1b2710b9655ae9b41716e478039df7a29c391c58 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 11:23:55 -0700 Subject: [PATCH 07/19] ref(issues): Add ActionSource.UNKNOWN enum value for missing context --- src/sentry/issues/action_log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index 33a1f5d084ca..f2800049f25e 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -43,6 +43,7 @@ class ActionSource(StrEnum): GITHUB = "github" GITLAB = "gitlab" JIRA = "jira" + UNKNOWN = "unknown" def _get_mcp_application_id() -> int | None: @@ -192,7 +193,7 @@ def publish_action_from_context( "publish_action_from_context called without ActionContext", extra={"action": action, "group_id": group_id}, ) - source = "unknown" + source = ActionSource.UNKNOWN actor_id = None else: source = ctx.source From ca12eb53991d0445a85ba6422bce2b4b2ff26452 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 12:58:49 -0700 Subject: [PATCH 08/19] fix(typing): Annotate source variable to satisfy mypy assignment check --- src/sentry/issues/action_log.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index f2800049f25e..62a4b00f47a3 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -193,7 +193,7 @@ def publish_action_from_context( "publish_action_from_context called without ActionContext", extra={"action": action, "group_id": group_id}, ) - source = ActionSource.UNKNOWN + source: str = ActionSource.UNKNOWN actor_id = None else: source = ctx.source From 5afd16b0baa9f5e7e85e93a01f362cc4f39f99ad Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 13:13:38 -0700 Subject: [PATCH 09/19] feat(issues): Set action context for system and integration callers, revert UNARCHIVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap system-initiated mutations (auto_update_priority, ingest assign, ownership auto-assign, release-based assign) with action_context_scope(source=SYSTEM) - Wrap integration sync assign/deassign with action_context_scope(source=integration.provider) - Revert UNARCHIVE action type — UNRESOLVE covers both transitions since "unresolved" is a real state, "unarchived" is not - Keep logger.error for missing context so unexpected callers surface as Sentry issues --- src/sentry/integrations/utils/sync.py | 21 ++++++++++++--------- src/sentry/issues/ingest.py | 23 +++++++++++++---------- src/sentry/issues/priority.py | 24 +++++++++++++++--------- src/sentry/models/projectownership.py | 16 +++++++++------- src/sentry/receivers/releases.py | 15 +++++++++------ 5 files changed, 58 insertions(+), 41 deletions(-) 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/ingest.py b/src/sentry/issues/ingest.py index 45d3541bbdd7..c52ca9a1de94 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): + 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): + 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 419d3f42951e..5677944ab670 100644 --- a/src/sentry/issues/priority.py +++ b/src/sentry/issues/priority.py @@ -4,7 +4,12 @@ from enum import StrEnum from typing import TYPE_CHECKING -from sentry.issues.action_log import ActionType, publish_action_from_context +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 @@ -175,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): + update_priority( + group=group, + priority=new_priority, + sender="auto_update_priority", + reason=reason, + actor=None, + project=group.project, + ) diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py index 6a775bdec1c8..b5c3bafc337e 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): + 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..0a322284d595 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,10 @@ 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): + 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 +263,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): + GroupAssignee.objects.assign( + group=group, assigned_to=acting_user, acting_user=acting_user + ) Activity.objects.create( project_id=group.project_id, From 2f23cc3dd840925efe6f6c800808d9fb2a359511 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Thu, 28 May 2026 13:32:00 -0700 Subject: [PATCH 10/19] ref(issues): Add all integration providers to ActionSource enum --- src/sentry/issues/action_log.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index 62a4b00f47a3..35474e4bdebc 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -38,11 +38,20 @@ class ActionSource(StrEnum): 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" From a114751c477008b4c728a0c9c0e7b1d0ca5762c5 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Fri, 29 May 2026 10:22:28 -0700 Subject: [PATCH 11/19] ref(issues): Address review feedback on action log instrumentation - Make actor_id required in action_context_scope (no silent default) - Raise RuntimeError in test environment when context is missing - Remove dead request=None branch from resolve_action_source - Add metrics.incr counter tagged by action and source --- src/sentry/issues/action_log.py | 15 ++++++++++----- src/sentry/issues/ingest.py | 2 +- src/sentry/issues/priority.py | 2 +- src/sentry/models/projectownership.py | 2 +- src/sentry/receivers/releases.py | 4 ++-- tests/sentry/issues/test_action_log.py | 25 ++++++++++++++++++++++--- 6 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index 35474e4bdebc..fd1c3889cf99 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -11,6 +11,7 @@ from rest_framework.request import Request from sentry.middleware import is_frontend_request +from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -75,10 +76,7 @@ def _get_mcp_application_id() -> int | None: return app_id if app_id > 0 else None -def resolve_action_source(request: Request | None) -> str: - if request is None: - return ActionSource.SYSTEM - +def resolve_action_source(request: Request) -> str: application_id = getattr(request.auth, "application_id", None) mcp_app_id = _get_mcp_application_id() if application_id is not None else None if mcp_app_id and application_id == mcp_app_id: @@ -148,7 +146,7 @@ class ActionContext: @contextmanager -def action_context_scope(source: str, actor_id: int | None = None) -> Generator[None]: +def action_context_scope(source: str, actor_id: int | None) -> Generator[None]: token = _action_context.set(ActionContext(source=source, actor_id=actor_id)) try: yield @@ -172,6 +170,7 @@ def publish_action( idempotency_key: str | None = None, ) -> None: 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={ @@ -198,6 +197,12 @@ def publish_action_from_context( ) -> None: ctx = get_action_context() if ctx is None: + from sentry.utils.env import in_test_environment + + if in_test_environment(): + raise RuntimeError( + f"publish_action_from_context called without ActionContext for {action}" + ) logger.error( "publish_action_from_context called without ActionContext", extra={"action": action, "group_id": group_id}, diff --git a/src/sentry/issues/ingest.py b/src/sentry/issues/ingest.py index c52ca9a1de94..45463613fedd 100644 --- a/src/sentry/issues/ingest.py +++ b/src/sentry/issues/ingest.py @@ -298,7 +298,7 @@ def save_issue_from_occurrence( try: # Since this calls hybrid cloud it has to be run outside the transaction assignee = occurrence.assignee.resolve() - with action_context_scope(source=ActionSource.SYSTEM): + 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") diff --git a/src/sentry/issues/priority.py b/src/sentry/issues/priority.py index 5677944ab670..14d2a104e7d6 100644 --- a/src/sentry/issues/priority.py +++ b/src/sentry/issues/priority.py @@ -180,7 +180,7 @@ 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: - with action_context_scope(source=ActionSource.SYSTEM): + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): update_priority( group=group, priority=new_priority, diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py index b5c3bafc337e..2c930b30ffee 100644 --- a/src/sentry/models/projectownership.py +++ b/src/sentry/models/projectownership.py @@ -359,7 +359,7 @@ def handle_auto_assignment( ): return - with action_context_scope(source=ActionSource.SYSTEM): + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): assignment = GroupAssignee.objects.assign( group, owner, diff --git a/src/sentry/receivers/releases.py b/src/sentry/receivers/releases.py index 0a322284d595..baa3dd8a9a1b 100644 --- a/src/sentry/receivers/releases.py +++ b/src/sentry/receivers/releases.py @@ -151,7 +151,7 @@ def resolved_in_commit(instance: Commit, created, **kwargs): if acting_user: if self_assign_issue == "1" and not group.assignee_set.exists(): - with action_context_scope(source=ActionSource.SYSTEM): + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): GroupAssignee.objects.assign( group=group, assigned_to=acting_user, acting_user=acting_user ) @@ -263,7 +263,7 @@ def resolved_in_pull_request(instance: PullRequest, created, **kwargs): acting_user: RpcUser | None = None if user_list: acting_user = user_list[0] - with action_context_scope(source=ActionSource.SYSTEM): + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): GroupAssignee.objects.assign( group=group, assigned_to=acting_user, acting_user=acting_user ) diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index fd98b7238c5a..2d7de32915b7 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -1,6 +1,8 @@ from typing import Any from unittest.mock import MagicMock, patch +import pytest + from sentry.issues.action_log import ( ActionContext, ActionType, @@ -39,9 +41,6 @@ def _make_request( class TestResolveActionSource(TestCase): - def test_no_request_returns_system(self) -> None: - assert resolve_action_source(None) == "system" - @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) def test_mcp_known_client(self, mock_get_id: MagicMock) -> None: request = _make_request( @@ -101,6 +100,13 @@ def test_scope_sets_and_resets(self) -> None: 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): @@ -152,6 +158,19 @@ def test_actor_type_derived_from_actor_id(self) -> None: assert logs.records[0].__dict__["actor_type"] == "system" +class TestPublishActionFromContext(TestCase): + def test_raises_in_test_environment_without_context(self) -> None: + with pytest.raises(RuntimeError, match="publish_action_from_context called without"): + from sentry.issues.action_log import publish_action_from_context + + publish_action_from_context( + action=ActionType.RESOLVE, + group_id=1, + organization_id=2, + project_id=3, + ) + + PUBLISH_UPDATE = "sentry.api.helpers.group_index.update.publish_action" PUBLISH_UPDATE_CTX = "sentry.api.helpers.group_index.update.publish_action_from_context" PUBLISH_DETAILS = "sentry.issues.endpoints.group_details.publish_action" From 8dd06ad3b154d80a6ebf415b46f3b8fab5230741 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Fri, 29 May 2026 10:50:11 -0700 Subject: [PATCH 12/19] fix(issues): Fix missing actor_id in ingest.py, revert test-env raise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RuntimeError in test environment broke every existing test that calls instrumented functions without action context. Keep logger.error as the safety net instead — it creates Sentry issues for triage without coupling unrelated tests to the action log system. --- src/sentry/issues/action_log.py | 6 ------ src/sentry/issues/ingest.py | 2 +- tests/sentry/issues/test_action_log.py | 12 +++++++----- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index fd1c3889cf99..154e21a23245 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -197,12 +197,6 @@ def publish_action_from_context( ) -> None: ctx = get_action_context() if ctx is None: - from sentry.utils.env import in_test_environment - - if in_test_environment(): - raise RuntimeError( - f"publish_action_from_context called without ActionContext for {action}" - ) logger.error( "publish_action_from_context called without ActionContext", extra={"action": action, "group_id": group_id}, diff --git a/src/sentry/issues/ingest.py b/src/sentry/issues/ingest.py index 45463613fedd..a0449b1a9390 100644 --- a/src/sentry/issues/ingest.py +++ b/src/sentry/issues/ingest.py @@ -336,7 +336,7 @@ def save_issue_from_occurrence( and group.priority != issue_kwargs["priority"] and group.priority_locked_at is None ): - with action_context_scope(source=ActionSource.SYSTEM): + with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): update_priority( group=group, priority=PriorityLevel(issue_kwargs["priority"]), diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index 2d7de32915b7..77587dc8ddf3 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -1,8 +1,6 @@ from typing import Any from unittest.mock import MagicMock, patch -import pytest - from sentry.issues.action_log import ( ActionContext, ActionType, @@ -159,16 +157,20 @@ def test_actor_type_derived_from_actor_id(self) -> None: class TestPublishActionFromContext(TestCase): - def test_raises_in_test_environment_without_context(self) -> None: - with pytest.raises(RuntimeError, match="publish_action_from_context called without"): - from sentry.issues.action_log import publish_action_from_context + 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" PUBLISH_UPDATE = "sentry.api.helpers.group_index.update.publish_action" From ab0f6b484df5da33f80a599401c01b68ebfa36d2 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Mon, 1 Jun 2026 09:48:37 -0700 Subject: [PATCH 13/19] docs(issues): Add documentation to action_log module and public interface --- src/sentry/issues/action_log.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index 154e21a23245..d44173a46d09 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -15,6 +15,16 @@ logger = logging.getLogger(__name__) +# Issue 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_CLIENT_NAME_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_NAME" SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" @@ -77,6 +87,9 @@ def _get_mcp_application_id() -> int | None: def resolve_action_source(request: Request) -> str: + """ + Determine the ActionSource from a request. Priority: MCP > Seer > frontend > CLI > API. + """ application_id = getattr(request.auth, "application_id", None) mcp_app_id = _get_mcp_application_id() if application_id is not None else None if mcp_app_id and application_id == mcp_app_id: @@ -147,6 +160,10 @@ class ActionContext: @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 @@ -169,6 +186,11 @@ def publish_action( 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( @@ -195,6 +217,12 @@ def publish_action_from_context( 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( From d475d4697f2aa9189976e82423b365173d3dff88 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Mon, 1 Jun 2026 13:05:48 -0700 Subject: [PATCH 14/19] ref(issues): Move cache import to top level, add seer priority test --- src/sentry/issues/action_log.py | 3 +-- tests/sentry/issues/test_action_log.py | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index d44173a46d09..b93dc8dcef3b 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -8,6 +8,7 @@ from enum import StrEnum from typing import Any +from django.core.cache import cache from rest_framework.request import Request from sentry.middleware import is_frontend_request @@ -67,8 +68,6 @@ class ActionSource(StrEnum): def _get_mcp_application_id() -> int | None: - from django.core.cache import cache - cached = cache.get(MCP_APP_ID_CACHE_KEY) if cached is not None: return cached if cached > 0 else None diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index 77587dc8ddf3..41e424b6232e 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -72,6 +72,14 @@ def test_seer_rpc_authenticator(self) -> None: 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 From fca57b608cd7d4b4824eb96e323975e34a7bbbb6 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Mon, 1 Jun 2026 17:06:19 -0700 Subject: [PATCH 15/19] ref(issues): Rename to Group Action Log, use patch.object with autospec, add UNKNOWN comment --- src/sentry/issues/action_log.py | 6 ++-- tests/sentry/issues/test_action_log.py | 45 +++++++++++++------------- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index b93dc8dcef3b..d7d91925d61d 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -# Issue Action Log — experimental module for tracking who did what to an issue and how. +# 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 @@ -64,7 +64,9 @@ class ActionSource(StrEnum): PAGERDUTY = "pagerduty" OPSGENIE = "opsgenie" PERFORCE = "perforce" - UNKNOWN = "unknown" + UNKNOWN = ( + "unknown" # fallback when ActionContext is missing; indicates a gap in instrumentation + ) def _get_mcp_application_id() -> int | None: diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index 41e424b6232e..08d3c4a1438a 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -1,6 +1,12 @@ 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, @@ -181,15 +187,6 @@ def test_logs_error_and_uses_unknown_without_context(self) -> None: assert info_record.__dict__["source"] == "unknown" -PUBLISH_UPDATE = "sentry.api.helpers.group_index.update.publish_action" -PUBLISH_UPDATE_CTX = "sentry.api.helpers.group_index.update.publish_action_from_context" -PUBLISH_DETAILS = "sentry.issues.endpoints.group_details.publish_action" -PUBLISH_STATUS_CTX = "sentry.issues.status_change.publish_action_from_context" -PUBLISH_ASSIGN_CTX = "sentry.models.groupassignee.publish_action_from_context" -PUBLISH_PRIORITY_CTX = "sentry.issues.priority.publish_action_from_context" -PUBLISH_INBOX_CTX = "sentry.models.groupinbox.publish_action_from_context" - - class TestActionLogIntegration(APITestCase, SnubaTestCase): def setUp(self) -> None: super().setUp() @@ -201,7 +198,9 @@ def setUp(self) -> None: ) self.url = f"/api/0/organizations/{self.organization.slug}/issues/{self.group.id}/" - @patch(PUBLISH_UPDATE_CTX) + @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 @@ -211,7 +210,9 @@ def test_resolve_emits_action(self, mock_publish: MagicMock) -> None: assert len(resolve_calls) == 1 assert resolve_calls[0].kwargs["group_id"] == self.group.id - @patch(PUBLISH_UPDATE_CTX) + @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") @@ -221,7 +222,7 @@ def test_resolve_already_resolved_skips(self, mock_publish: MagicMock) -> None: ] assert len(resolve_calls) == 0 - @patch(PUBLISH_STATUS_CTX) + @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, @@ -232,7 +233,7 @@ def test_archive_emits_action(self, mock_publish: MagicMock) -> None: mock_publish.assert_called_once() assert mock_publish.call_args.kwargs["action"] == ActionType.ARCHIVE - @patch(PUBLISH_STATUS_CTX) + @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( @@ -243,20 +244,20 @@ def test_archive_already_archived_skips(self, mock_publish: MagicMock) -> None: assert response.status_code == 200 mock_publish.assert_not_called() - @patch(PUBLISH_PRIORITY_CTX) + @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(PUBLISH_PRIORITY_CTX) + @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(PUBLISH_ASSIGN_CTX) + @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" @@ -265,14 +266,14 @@ def test_assign_emits_action(self, mock_publish: MagicMock) -> None: mock_publish.assert_called_once() assert mock_publish.call_args.kwargs["action"] == ActionType.ASSIGN - @patch(PUBLISH_ASSIGN_CTX) + @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(PUBLISH_ASSIGN_CTX) + @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() @@ -283,20 +284,20 @@ def test_unassign_emits_action(self, mock_publish: MagicMock) -> None: ] assert len(unassign_calls) == 1 - @patch(PUBLISH_ASSIGN_CTX) + @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(PUBLISH_DETAILS) + @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(PUBLISH_INBOX_CTX) + @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 @@ -321,7 +322,7 @@ def test_mark_reviewed_emits_for_inbox_groups(self, mock_publish: MagicMock) -> assert len(reviewed_calls) == 1 assert reviewed_calls[0].kwargs["group_id"] == group_in_inbox.id - @patch(PUBLISH_UPDATE) + @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}" From 1fba4f0461f7292b3e5f5c34072132fc8671f97d Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Tue, 2 Jun 2026 13:23:22 -0700 Subject: [PATCH 16/19] ref(issues): Identify MCP source via User-Agent header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ApiApplication name lookup (non-deterministic and spoofable) with a check on the User-Agent the MCP sends (sentry-mcp/...), mirroring how sentry-cli is detected. The X-Sentry-MCP-Client-Name header still provides the sub-type (mcp:claude-code, etc.). Source attribution is self-reported and spoofable by design — best-effort, not for audit. --- src/sentry/issues/action_log.py | 33 +++---------------- tests/sentry/issues/test_action_log.py | 45 +++++++++++++------------- 2 files changed, 27 insertions(+), 51 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index d7d91925d61d..c27c238ad5e0 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -8,7 +8,6 @@ from enum import StrEnum from typing import Any -from django.core.cache import cache from rest_framework.request import Request from sentry.middleware import is_frontend_request @@ -27,11 +26,9 @@ # wrap it with action_context_scope() so the action gets proper source attribution. MCP_CLIENT_NAME_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_NAME" +MCP_USER_AGENT_PREFIX = "sentry-mcp/" SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" -MCP_APP_ID_CACHE_KEY = "action_log.mcp_application_id" -MCP_APP_ID_CACHE_TTL = 300 - KNOWN_MCP_CLIENTS: dict[str, str] = { "claude code": "claude-code", "claude-code": "claude-code", @@ -69,34 +66,15 @@ class ActionSource(StrEnum): ) -def _get_mcp_application_id() -> int | None: - cached = cache.get(MCP_APP_ID_CACHE_KEY) - if cached is not None: - return cached if cached > 0 else None - - from sentry.models.apiapplication import ApiApplication - - try: - app = ApiApplication.objects.filter(name__icontains="sentry-mcp").first() - app_id = app.id if app else 0 - except Exception: - logger.exception("Failed to look up MCP application ID") - return None - - cache.set(MCP_APP_ID_CACHE_KEY, app_id, MCP_APP_ID_CACHE_TTL) - return app_id if app_id > 0 else None - - def resolve_action_source(request: Request) -> str: """ Determine the ActionSource from a request. Priority: MCP > Seer > frontend > CLI > API. """ - application_id = getattr(request.auth, "application_id", None) - mcp_app_id = _get_mcp_application_id() if application_id is not None else None - if mcp_app_id and application_id == mcp_app_id: + user_agent = request.META.get("HTTP_USER_AGENT", "") + + if user_agent.startswith(MCP_USER_AGENT_PREFIX): client_name = request.META.get(MCP_CLIENT_NAME_HEADER, "") - normalized = client_name.strip().lower() - slug = KNOWN_MCP_CLIENTS.get(normalized) + slug = KNOWN_MCP_CLIENTS.get(client_name.strip().lower()) if slug: return f"{ActionSource.MCP}:{slug}" return ActionSource.MCP @@ -117,7 +95,6 @@ def resolve_action_source(request: Request) -> str: if is_frontend_request(request): return ActionSource.WEB - user_agent = request.META.get("HTTP_USER_AGENT", "") if user_agent.startswith("sentry-cli/"): return ActionSource.SENTRY_CLI diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index 08d3c4a1438a..cf28c8b3c073 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -23,7 +23,6 @@ def _make_request( *, - application_id: int | None = None, meta: dict[str, str] | None = None, cookies: dict[str, str] | None = None, auth: Any = None, @@ -32,41 +31,41 @@ def _make_request( request = MagicMock() request.META = meta or {} request.COOKIES = cookies or {} - - if auth is not None: - request.auth = auth - else: - token = MagicMock() - token.application_id = application_id - request.auth = token - + 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): - @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) - def test_mcp_known_client(self, mock_get_id: MagicMock) -> None: + def test_mcp_known_client(self) -> None: request = _make_request( - application_id=42, - meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code"}, + meta={ + "HTTP_USER_AGENT": MCP_USER_AGENT, + "HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code", + }, ) assert resolve_action_source(request) == "mcp:claude-code" - @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) - def test_mcp_unknown_client(self, mock_get_id: MagicMock) -> None: + def test_mcp_unknown_client(self) -> None: request = _make_request( - application_id=42, - meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "some-new-editor"}, + meta={ + "HTTP_USER_AGENT": MCP_USER_AGENT, + "HTTP_X_SENTRY_MCP_CLIENT_NAME": "some-new-editor", + }, ) assert resolve_action_source(request) == "mcp" - @patch("sentry.issues.action_log._get_mcp_application_id", return_value=42) - def test_mcp_header_ignored_when_wrong_application(self, mock_get_id: MagicMock) -> None: - request = _make_request( - application_id=999, - meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code"}, - ) + def test_mcp_without_client_name(self) -> None: + request = _make_request(meta={"HTTP_USER_AGENT": MCP_USER_AGENT}) + assert resolve_action_source(request) == "mcp" + + def test_mcp_client_name_without_user_agent_is_not_mcp(self) -> None: + # The MCP source is gated on the User-Agent, not the client-name header, so the + # header alone does not flip the source to mcp. + request = _make_request(meta={"HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code"}) assert resolve_action_source(request) == "api" def test_seer_referrer(self) -> None: From 90f205f552d192b4fef99f4dc182d4120b8ea10e Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Tue, 2 Jun 2026 13:59:19 -0700 Subject: [PATCH 17/19] ref(issues): Resolve MCP client sub-type from client-family header Use the standardized X-Sentry-MCP-Client-Family header (claude-code, cursor, etc.) instead of the free-form client-name, so sub-type values are predictable. Unrecognized families are logged so new ones can be added; the MCP's own "other"/"unknown" catch-alls fall back to plain mcp. --- src/sentry/issues/action_log.py | 28 +++++++++++++----------- tests/sentry/issues/test_action_log.py | 30 ++++++++++++++++++-------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/src/sentry/issues/action_log.py b/src/sentry/issues/action_log.py index c27c238ad5e0..90808a62c801 100644 --- a/src/sentry/issues/action_log.py +++ b/src/sentry/issues/action_log.py @@ -25,17 +25,16 @@ # 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_CLIENT_NAME_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_NAME" MCP_USER_AGENT_PREFIX = "sentry-mcp/" +MCP_CLIENT_FAMILY_HEADER = "HTTP_X_SENTRY_MCP_CLIENT_FAMILY" SEER_REFERRER_HEADER = "HTTP_X_SEER_REFERRER" -KNOWN_MCP_CLIENTS: dict[str, str] = { - "claude code": "claude-code", - "claude-code": "claude-code", - "cursor": "cursor", - "copilot": "copilot", - "windsurf": "windsurf", -} +# 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): @@ -73,10 +72,15 @@ def resolve_action_source(request: Request) -> str: user_agent = request.META.get("HTTP_USER_AGENT", "") if user_agent.startswith(MCP_USER_AGENT_PREFIX): - client_name = request.META.get(MCP_CLIENT_NAME_HEADER, "") - slug = KNOWN_MCP_CLIENTS.get(client_name.strip().lower()) - if slug: - return f"{ActionSource.MCP}:{slug}" + 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, "") diff --git a/tests/sentry/issues/test_action_log.py b/tests/sentry/issues/test_action_log.py index cf28c8b3c073..e2ba3ae58ed1 100644 --- a/tests/sentry/issues/test_action_log.py +++ b/tests/sentry/issues/test_action_log.py @@ -40,32 +40,44 @@ def _make_request( class TestResolveActionSource(TestCase): - def test_mcp_known_client(self) -> None: + def test_mcp_known_family(self) -> None: request = _make_request( meta={ "HTTP_USER_AGENT": MCP_USER_AGENT, - "HTTP_X_SENTRY_MCP_CLIENT_NAME": "Claude Code", + "HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "claude-code", }, ) assert resolve_action_source(request) == "mcp:claude-code" - def test_mcp_unknown_client(self) -> None: + 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_NAME": "some-new-editor", + "HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "some-new-editor", }, ) - assert resolve_action_source(request) == "mcp" + 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_client_name(self) -> None: + 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_client_name_without_user_agent_is_not_mcp(self) -> None: - # The MCP source is gated on the User-Agent, not the client-name header, so the + 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_NAME": "Claude Code"}) + request = _make_request(meta={"HTTP_X_SENTRY_MCP_CLIENT_FAMILY": "claude-code"}) assert resolve_action_source(request) == "api" def test_seer_referrer(self) -> None: From e1aececdb9157823bf606434e05e54750a69bb9e Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Tue, 2 Jun 2026 14:31:31 -0700 Subject: [PATCH 18/19] fix(issues): Attribute commit/PR self-assign to the acting user resolved_in_commit and resolved_in_pull_request wrapped the self-assign in action_context_scope with actor_id=None while passing acting_user into assign(), so the action log recorded ASSIGN as a system action with no actor. Attribute it to acting_user, matching the activity and group history records. --- src/sentry/receivers/releases.py | 6 ++++-- tests/sentry/receivers/test_releases.py | 22 +++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/sentry/receivers/releases.py b/src/sentry/receivers/releases.py index baa3dd8a9a1b..51e5a6fb4151 100644 --- a/src/sentry/receivers/releases.py +++ b/src/sentry/receivers/releases.py @@ -151,7 +151,9 @@ def resolved_in_commit(instance: Commit, created, **kwargs): if acting_user: if self_assign_issue == "1" and not group.assignee_set.exists(): - with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): + 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 ) @@ -263,7 +265,7 @@ def resolved_in_pull_request(instance: PullRequest, created, **kwargs): acting_user: RpcUser | None = None if user_list: acting_user = user_list[0] - with action_context_scope(source=ActionSource.SYSTEM, actor_id=None): + 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 ) 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 == { From b2c43ce0aa4cf953797c41bbf79de246e3a41092 Mon Sep 17 00:00:00 2001 From: Yuval Mandelboum Date: Tue, 2 Jun 2026 14:42:53 -0700 Subject: [PATCH 19/19] ref(issues): Defer autofix action-log instrumentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST handler logged TRIGGER_AUTOFIX on any 202, but _post_inner returns 202 for coding_agent_handoff and open_pr too, not only a built-in autofix run — so non-autofix steps were recorded as autofix triggers. Remove the instrumentation (and fold _post_inner back into post) until it can target the actual autofix run. Tracked in ID-1591. --- src/sentry/seer/endpoints/group_ai_autofix.py | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index f61ab3af5733..f2cba8afd9b6 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -24,7 +24,6 @@ from sentry.apidocs.parameters import GlobalParams, IssueParams from sentry.apidocs.utils import inline_sentry_response_serializer 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 GroupAiEndpoint from sentry.models.group import Group from sentry.ratelimits.config import RateLimitConfig @@ -182,23 +181,6 @@ def post(self, request: Request, group: Group) -> Response: The process runs asynchronously, and you can get the state using the GET endpoint. """ - response = self._post_inner(request, group) - - if response.status_code == status.HTTP_202_ACCEPTED: - publish_action( - action=ActionType.TRIGGER_AUTOFIX, - 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, - ) - - return response - - def _post_inner(self, request: Request, group: Group) -> Response: serializer = ExplorerAutofixRequestSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)