From 7867a9dc48fc3e6c1b55b4528261c5588890a55c Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 13:58:44 -0700 Subject: [PATCH 1/3] feat(investigations): Return action requests on investigation details Part of a series that adds follow-up actions to Seer investigations: when a run completes, Seer suggests one next step, and the user can apply a typed action, such as a monitor change, with one click. Add Seer's live action requests to the investigation details response, so the UI can show the recommended action and the requests under their blocks and hypotheses. --- .../endpoints/serializers/__init__.py | 6 + .../endpoints/serializers/action_request.py | 140 +++++++++++++ .../endpoints/serializers/investigation.py | 34 ++++ .../serializers/test_investigation.py | 184 +++++++++++++++++- ...test_organization_investigation_details.py | 36 ++++ 5 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 src/sentry/investigations/endpoints/serializers/action_request.py diff --git a/src/sentry/investigations/endpoints/serializers/__init__.py b/src/sentry/investigations/endpoints/serializers/__init__.py index 6828b4ab4734..8d19b83aac73 100644 --- a/src/sentry/investigations/endpoints/serializers/__init__.py +++ b/src/sentry/investigations/endpoints/serializers/__init__.py @@ -1,4 +1,6 @@ __all__ = ( + "InvestigationActionRequestSerializer", + "InvestigationActionRequestSerializerResponse", "InvestigationBlockSerializer", "InvestigationBlockSerializerResponse", "InvestigationDetailsSerializer", @@ -11,6 +13,10 @@ ) +from .action_request import ( + InvestigationActionRequestSerializer, + InvestigationActionRequestSerializerResponse, +) from .block import InvestigationBlockSerializer, InvestigationBlockSerializerResponse from .investigation import ( InvestigationDetailsSerializer, diff --git a/src/sentry/investigations/endpoints/serializers/action_request.py b/src/sentry/investigations/endpoints/serializers/action_request.py new file mode 100644 index 000000000000..bf6d0e119054 --- /dev/null +++ b/src/sentry/investigations/endpoints/serializers/action_request.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from collections.abc import Mapping, MutableMapping, Sequence +from datetime import datetime +from typing import Any, TypedDict, override + +from django.contrib.auth.models import AnonymousUser + +from sentry.api.serializers import Serializer, register +from sentry.investigations.models import ( + InvestigationActionRequest, + InvestigationActionRequestAnchorType, + InvestigationBlock, +) +from sentry.users.api.serializers.user import SerializedAvatarFields +from sentry.users.models.user import User +from sentry.users.services.user.model import RpcUser + + +class InvestigationActionRequestAnchorResponse(TypedDict): + """id is Seer's: a block's agent key or a hypothesis ID. blockId is the live block, if any.""" + + type: str + id: str + blockId: str | None + + +class InvestigationActionRequestActorResponse(TypedDict): + id: str + name: str + username: str + email: str + avatarUrl: str + avatar: SerializedAvatarFields + + +class InvestigationActionRequestSerializerResponse(TypedDict): + """ + A next step Seer asked for. `primary` marks the suggested next step after a run, and + `anchor` places a request under a block or hypothesis. `action` is null for advice. + `appliedDetails` depends on the action kind, and `appliedBy` is null for an automatic apply. + """ + + id: str + key: str + reportRevision: int + primary: bool + anchor: InvestigationActionRequestAnchorResponse | None + title: str + rationale: str + impact: str | None + state: str + reason: str | None + action: dict[str, Any] | None + appliedBy: InvestigationActionRequestActorResponse | None + appliedDetails: dict[str, Any] | None + dateApplied: datetime | None + error: dict[str, Any] | None + dateCreated: datetime + + +@register(InvestigationActionRequest) +class InvestigationActionRequestSerializer( + Serializer[InvestigationActionRequestSerializerResponse] +): + def _anchored_block_ids( + self, item_list: Sequence[InvestigationActionRequest] + ) -> dict[tuple[int, str], str]: + keys = { + item.anchor_id + for item in item_list + if item.anchor_type == InvestigationActionRequestAnchorType.BLOCK and item.anchor_id + } + if not keys: + return {} + return { + (investigation_id, key): str(block_id) + for investigation_id, key, block_id in InvestigationBlock.objects.filter( + investigation_id__in={item.investigation_id for item in item_list}, + stable_agent_key__in=keys, + deleted_at__isnull=True, + ).values_list("investigation_id", "stable_agent_key", "id") + if key is not None + } + + @override + def get_attrs( + self, + item_list: Sequence[InvestigationActionRequest], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> MutableMapping[InvestigationActionRequest, dict[str, Any]]: + anchored_blocks = self._anchored_block_ids(item_list) + return { + item: { + "block_id": ( + anchored_blocks.get((item.investigation_id, item.anchor_id or "")) + if item.anchor_type == InvestigationActionRequestAnchorType.BLOCK + else None + ) + } + for item in item_list + } + + @override + def serialize( + self, + obj: InvestigationActionRequest, + attrs: Mapping[Any, Any], + user: User | RpcUser | AnonymousUser, + **kwargs: Any, + ) -> InvestigationActionRequestSerializerResponse: + return { + "id": str(obj.id), + "key": obj.key, + "reportRevision": obj.report_revision, + "primary": obj.primary, + "anchor": ( + {"type": obj.anchor_type, "id": obj.anchor_id, "blockId": attrs["block_id"]} + if obj.anchor_type is not None and obj.anchor_id is not None + else None + ), + "title": obj.title, + "rationale": obj.rationale, + "impact": obj.impact, + "state": obj.state, + "reason": obj.reason, + "action": obj.action_payload, + "appliedBy": ( + (obj.applied_details or {}).get("actor") if obj.applied_by_id is not None else None + ), + "appliedDetails": ( + {key: value for key, value in obj.applied_details.items() if key != "actor"} + if obj.applied_details is not None + else None + ), + "dateApplied": obj.date_applied, + "error": obj.error, + "dateCreated": obj.date_added, + } diff --git a/src/sentry/investigations/endpoints/serializers/investigation.py b/src/sentry/investigations/endpoints/serializers/investigation.py index 472ad298b91b..77ceaa5aa6f6 100644 --- a/src/sentry/investigations/endpoints/serializers/investigation.py +++ b/src/sentry/investigations/endpoints/serializers/investigation.py @@ -9,6 +9,10 @@ from django.db.models import Count, Q from sentry.api.serializers import Serializer, register, serialize +from sentry.investigations.endpoints.serializers.action_request import ( + InvestigationActionRequestSerializer, + InvestigationActionRequestSerializerResponse, +) from sentry.investigations.endpoints.serializers.block import ( InvestigationBlockSerializer, InvestigationBlockSerializerResponse, @@ -19,6 +23,8 @@ ) from sentry.investigations.models import ( Investigation, + InvestigationActionRequest, + InvestigationActionRequestState, InvestigationBlock, InvestigationFavoriteUser, InvestigationOrchestrationRun, @@ -111,6 +117,7 @@ class InvestigationDetailsSerializerResponse(InvestigationSerializerResponse): """ Full notebook with ordered active blocks, parameter definitions and saved values, project IDs, and source context. source.snapshot is present only for source-backed investigations. + actionRequests lists Seer's live action requests, the primary one first. """ template: InvestigationTemplateSerializerResponse | None @@ -119,6 +126,7 @@ class InvestigationDetailsSerializerResponse(InvestigationSerializerResponse): projectIds: list[int] parameters: list[InvestigationParameterSerializerResponse] blocks: list[InvestigationBlockSerializerResponse] + actionRequests: list[InvestigationActionRequestSerializerResponse] @register(Investigation) @@ -228,6 +236,27 @@ def _parameters_by_investigation( by_investigation[parameter.investigation_id].append(parameter_response) return by_investigation + def _action_requests_by_investigation( + self, item_list: Sequence[Investigation], user: User | RpcUser | AnonymousUser + ) -> MutableMapping[int, list[InvestigationActionRequestSerializerResponse]]: + action_requests = list( + InvestigationActionRequest.objects.filter(investigation__in=item_list) + .exclude(state=InvestigationActionRequestState.SUPERSEDED) + .order_by("-primary", "-report_revision", "id") + ) + serialized = serialize( + action_requests, + user, + InvestigationActionRequestSerializer(), + ) + + by_investigation: MutableMapping[ + int, list[InvestigationActionRequestSerializerResponse] + ] = defaultdict(list) + for action_request, response in zip(action_requests, serialized): + by_investigation[action_request.investigation_id].append(response) + return by_investigation + @override def get_attrs( self, @@ -239,6 +268,7 @@ def get_attrs( blocks_by_investigation = self._blocks_by_investigation(item_list, user) parameters_by_investigation = self._parameters_by_investigation(item_list, user) + action_requests_by_investigation = self._action_requests_by_investigation(item_list, user) project_ids_by_investigation: MutableMapping[int, list[int]] = defaultdict(list) for investigation_id, project_id in ( @@ -252,6 +282,9 @@ def get_attrs( attrs[investigation]["blocks"] = blocks_by_investigation[investigation.id] attrs[investigation]["parameters"] = parameters_by_investigation[investigation.id] attrs[investigation]["project_ids"] = project_ids_by_investigation[investigation.id] + attrs[investigation]["action_requests"] = action_requests_by_investigation[ + investigation.id + ] return attrs @@ -284,5 +317,6 @@ def serialize( "projectIds": attrs["project_ids"], "parameters": attrs["parameters"], "blocks": attrs["blocks"], + "actionRequests": attrs["action_requests"], "titleGeneration": {"status": obj.title_generation_status}, } diff --git a/tests/sentry/investigations/endpoints/serializers/test_investigation.py b/tests/sentry/investigations/endpoints/serializers/test_investigation.py index eb8ebdf3ebb9..e8da0cbdf9d5 100644 --- a/tests/sentry/investigations/endpoints/serializers/test_investigation.py +++ b/tests/sentry/investigations/endpoints/serializers/test_investigation.py @@ -7,12 +7,18 @@ from django.utils import timezone from sentry.api.serializers import serialize +from sentry.incidents.grouptype import MetricIssue from sentry.investigations.endpoints.serializers import ( InvestigationDetailsSerializer, InvestigationDetailsSerializerResponse, InvestigationSerializer, ) -from sentry.investigations.models import Investigation, InvestigationBlockExecutionStatus +from sentry.investigations.models import ( + Investigation, + InvestigationActionRequest, + InvestigationActionRequestState, + InvestigationBlockExecutionStatus, +) from sentry.testutils.cases import TestCase @@ -273,3 +279,179 @@ def build(index: int) -> Investigation: assert len(results) == 5 assert len(many.captured_queries) == len(one.captured_queries) + + +class InvestigationActionRequestSerializationTest(TestCase): + def setUp(self) -> None: + super().setUp() + self.detector = self.create_detector(project=self.project, type=MetricIssue.slug) + self.investigation = self.create_investigation( + organization=self.organization, created_by=self.user, title="Investigation" + ) + self.create_investigation_orchestration_run( + investigation=self.investigation, + source={"type": "breached_metric", "detectorId": str(self.detector.id)}, + ) + + def action(self, **values: Any) -> dict[str, Any]: + return { + "kind": "update_detector", + "detectorId": str(self.detector.id), + "before": {"timeWindowSeconds": 300}, + "changes": {"timeWindowSeconds": 900}, + **values, + } + + def add_request(self, key: str = "widen-window", **values: Any) -> InvestigationActionRequest: + action = values.pop("action", self.action()) + return self.create_investigation_action_request( + investigation=self.investigation, + key=key, + action_kind=action["kind"] if action else None, + action_payload=action, + **values, + ) + + def serialize_requests(self) -> list[dict[str, Any]]: + return serialize(self.investigation, self.user, InvestigationDetailsSerializer())[ + "actionRequests" + ] + + def test_serializes_an_action_request(self) -> None: + action_request = self.add_request( + impact="Fewer alerts.", primary=True, anchor_type="block", anchor_id="b-1" + ) + + assert self.serialize_requests() == [ + { + "id": str(action_request.id), + "key": "widen-window", + "reportRevision": 0, + "primary": True, + "anchor": {"type": "block", "id": "b-1", "blockId": None}, + "title": "An action request", + "rationale": "Because it helps.", + "impact": "Fewer alerts.", + "state": "suggested", + "reason": None, + "action": self.action(), + "appliedBy": None, + "appliedDetails": None, + "dateApplied": None, + "error": None, + "dateCreated": action_request.date_added, + } + ] + + def test_block_anchors_point_at_the_live_block(self) -> None: + live = self.create_investigation_block( + investigation=self.investigation, position=0, stable_agent_key="cause" + ) + self.create_investigation_block( + investigation=self.investigation, + position=1, + stable_agent_key="removed", + deleted_at=timezone.now(), + ) + for key, anchor_id in [("live", "cause"), ("gone", "removed")]: + self.add_request(key, anchor_type="block", anchor_id=anchor_id) + self.add_request("hypothesis", anchor_type="hypothesis", anchor_id="h-1") + self.add_request("same-id", anchor_type="hypothesis", anchor_id="cause") + + assert [row["anchor"] for row in self.serialize_requests()] == [ + {"type": "block", "id": "cause", "blockId": str(live.id)}, + {"type": "block", "id": "removed", "blockId": None}, + {"type": "hypothesis", "id": "h-1", "blockId": None}, + {"type": "hypothesis", "id": "cause", "blockId": None}, + ] + + def test_serializes_requests_without_a_known_action(self) -> None: + self.add_request(action=None, primary=True) + self.add_request("task", action={"kind": "seer_task", "prompt": "Add spans"}) + + assert [row["action"] for row in self.serialize_requests()] == [ + None, + {"kind": "seer_task", "prompt": "Add spans"}, + ] + + def test_orders_the_primary_first_and_omits_superseded_requests(self) -> None: + older = self.add_request("older", report_revision=0) + self.add_request("superseded", state=InvestigationActionRequestState.SUPERSEDED) + second = self.add_request("second", report_revision=1) + first = self.add_request("first", report_revision=0, primary=True) + + assert [row["id"] for row in self.serialize_requests()] == [ + str(first.id), + str(second.id), + str(older.id), + ] + + def test_serializes_who_applied_it_failures_and_withdrawals(self) -> None: + now = timezone.now() + actor = {"id": str(self.user.id), "name": "Ada Lovelace"} + self.add_request( + state=InvestigationActionRequestState.APPLIED, + applied_by_id=self.user.id, + date_applied=now, + applied_details={"provider": "datadog", "rerun": None, "actor": actor}, + ) + self.add_request( + "failed", + state=InvestigationActionRequestState.FAILED, + error={"code": "validation_failed", "detail": {"dataSources": ["Bad window."]}}, + ) + self.add_request( + "withdrawn", + state=InvestigationActionRequestState.WITHDRAWN, + reason="The spikes are real.", + ) + + applied, failed, withdrawn = self.serialize_requests() + + assert applied["appliedBy"] == actor + assert applied["appliedDetails"] == {"provider": "datadog", "rerun": None} + assert applied["dateApplied"] == now + assert failed["state"] == "failed" + assert failed["error"] == { + "code": "validation_failed", + "detail": {"dataSources": ["Bad window."]}, + } + assert (withdrawn["state"], withdrawn["reason"]) == ("withdrawn", "The spikes are real.") + + def test_hides_the_stored_applier_once_the_user_is_gone(self) -> None: + self.add_request( + state=InvestigationActionRequestState.APPLIED, + applied_by_id=None, + applied_details={"provider": "datadog", "rerun": None, "actor": {"id": "1"}}, + ) + + (applied,) = self.serialize_requests() + + assert applied["appliedBy"] is None + + def test_query_count_does_not_grow_with_action_requests(self) -> None: + self.add_request("first-suggestion") + self.add_request( + state=InvestigationActionRequestState.APPLIED, + applied_by_id=self.user.id, + date_applied=timezone.now(), + ) + self.add_request("connect", action={"kind": "connect_telemetry", "provider": "datadog"}) + with CaptureQueriesContext(connection) as one: + self.serialize_requests() + + other = self.create_user() + self.add_request("second") + self.add_request( + "third", + state=InvestigationActionRequestState.APPLIED, + applied_by_id=other.id, + date_applied=timezone.now(), + ) + self.add_request("connect-gcp", action={"kind": "connect_telemetry", "provider": "gcp"}) + for index in range(3): + self.add_request(f"older-{index}", report_revision=1) + with CaptureQueriesContext(connection) as many: + assert len(self.serialize_requests()) == 9 + + assert len(many.captured_queries) == len(one.captured_queries) diff --git a/tests/sentry/investigations/endpoints/test_organization_investigation_details.py b/tests/sentry/investigations/endpoints/test_organization_investigation_details.py index 600ce1a88389..3eac482a6582 100644 --- a/tests/sentry/investigations/endpoints/test_organization_investigation_details.py +++ b/tests/sentry/investigations/endpoints/test_organization_investigation_details.py @@ -4,6 +4,7 @@ from django.urls import reverse +from sentry.incidents.grouptype import MetricIssue from sentry.investigations.models import ( Investigation, InvestigationBlockExecutionStatus, @@ -268,6 +269,41 @@ def test_agentic_title_update_marks_a_manual_override(self) -> None: investigation.refresh_from_db() assert investigation.title == "My incident title" + def test_detail_includes_action_requests(self) -> None: + detector = self.create_detector(project=self.project, type=MetricIssue.slug) + investigation = self.create_investigation( + organization=self.organization, created_by=self.user, title="Breach" + ) + self.create_investigation_orchestration_run( + investigation=investigation, + source={"type": "breached_metric", "detectorId": str(detector.id)}, + ) + action_request = self.create_investigation_action_request( + investigation=investigation, + key="widen-window", + action_kind="update_detector", + action_payload={ + "kind": "update_detector", + "detectorId": str(detector.id), + "before": {"timeWindowSeconds": 300}, + "changes": {"timeWindowSeconds": 900}, + }, + ) + url = reverse( + "sentry-api-0-organization-investigation-details", + kwargs={ + "organization_id_or_slug": self.organization.slug, + "investigation_id": investigation.id, + }, + ) + + response = self.client.get(url) + + assert response.status_code == 200 + [serialized] = response.data["actionRequests"] + assert serialized["id"] == str(action_request.id) + assert serialized["action"] == action_request.action_payload + def test_metadata_update_persists_and_stale_version_rolls_back(self) -> None: created = self.client.post( self.collection_url, From 8bf7b2ad056db3662eb2ff80f186ced322f3be27 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 14:55:28 -0700 Subject: [PATCH 2/3] ref(investigations): Reword comments Make the comments read plainly. --- .../endpoints/serializers/action_request.py | 11 +++++++---- .../endpoints/serializers/investigation.py | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/sentry/investigations/endpoints/serializers/action_request.py b/src/sentry/investigations/endpoints/serializers/action_request.py index bf6d0e119054..985fa37de774 100644 --- a/src/sentry/investigations/endpoints/serializers/action_request.py +++ b/src/sentry/investigations/endpoints/serializers/action_request.py @@ -18,7 +18,9 @@ class InvestigationActionRequestAnchorResponse(TypedDict): - """id is Seer's: a block's agent key or a hypothesis ID. blockId is the live block, if any.""" + """`id` is Seer's ID: a block's agent key or a hypothesis ID. `blockId` is the live block + it points to, if there is one. + """ type: str id: str @@ -36,9 +38,10 @@ class InvestigationActionRequestActorResponse(TypedDict): class InvestigationActionRequestSerializerResponse(TypedDict): """ - A next step Seer asked for. `primary` marks the suggested next step after a run, and - `anchor` places a request under a block or hypothesis. `action` is null for advice. - `appliedDetails` depends on the action kind, and `appliedBy` is null for an automatic apply. + A step Seer suggests. `primary` marks the recommended next step after a run, and `anchor` + puts a request under a block or hypothesis. `action` is null when it's only advice. What's + in `appliedDetails` depends on the action kind, and `appliedBy` is null when Sentry applied + it automatically. """ id: str diff --git a/src/sentry/investigations/endpoints/serializers/investigation.py b/src/sentry/investigations/endpoints/serializers/investigation.py index 77ceaa5aa6f6..e4a152f95cc1 100644 --- a/src/sentry/investigations/endpoints/serializers/investigation.py +++ b/src/sentry/investigations/endpoints/serializers/investigation.py @@ -117,7 +117,7 @@ class InvestigationDetailsSerializerResponse(InvestigationSerializerResponse): """ Full notebook with ordered active blocks, parameter definitions and saved values, project IDs, and source context. source.snapshot is present only for source-backed investigations. - actionRequests lists Seer's live action requests, the primary one first. + actionRequests has Seer's live action requests, with the primary one first. """ template: InvestigationTemplateSerializerResponse | None From 8ba3b41e1675e9792ff978713f5d898517922a95 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Wed, 21 Oct 2026 15:28:46 -0700 Subject: [PATCH 3/3] fix(investigations): Resolve who applied a request on read Look the applier up with the usual user serializer, so names and avatars stay current and no user data is copied into the request, and list the newest report's requests first. --- .../endpoints/serializers/action_request.py | 39 +++++++++---------- .../endpoints/serializers/investigation.py | 5 ++- .../serializers/test_investigation.py | 27 ++++++++----- 3 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/sentry/investigations/endpoints/serializers/action_request.py b/src/sentry/investigations/endpoints/serializers/action_request.py index 985fa37de774..fc0e5f3b260d 100644 --- a/src/sentry/investigations/endpoints/serializers/action_request.py +++ b/src/sentry/investigations/endpoints/serializers/action_request.py @@ -12,9 +12,11 @@ InvestigationActionRequestAnchorType, InvestigationBlock, ) -from sentry.users.api.serializers.user import SerializedAvatarFields +from sentry.users.api.serializers.user import UserSerializerResponse from sentry.users.models.user import User from sentry.users.services.user.model import RpcUser +from sentry.users.services.user.serial import serialize_generic_user +from sentry.users.services.user.service import user_service class InvestigationActionRequestAnchorResponse(TypedDict): @@ -27,15 +29,6 @@ class InvestigationActionRequestAnchorResponse(TypedDict): blockId: str | None -class InvestigationActionRequestActorResponse(TypedDict): - id: str - name: str - username: str - email: str - avatarUrl: str - avatar: SerializedAvatarFields - - class InvestigationActionRequestSerializerResponse(TypedDict): """ A step Seer suggests. `primary` marks the recommended next step after a run, and `anchor` @@ -55,7 +48,7 @@ class InvestigationActionRequestSerializerResponse(TypedDict): state: str reason: str | None action: dict[str, Any] | None - appliedBy: InvestigationActionRequestActorResponse | None + appliedBy: UserSerializerResponse | None appliedDetails: dict[str, Any] | None dateApplied: datetime | None error: dict[str, Any] | None @@ -94,13 +87,25 @@ def get_attrs( **kwargs: Any, ) -> MutableMapping[InvestigationActionRequest, dict[str, Any]]: anchored_blocks = self._anchored_block_ids(item_list) + applier_ids = {item.applied_by_id for item in item_list if item.applied_by_id is not None} + appliers = ( + { + applier["id"]: applier + for applier in user_service.serialize_many( + filter={"user_ids": list(applier_ids)}, as_user=serialize_generic_user(user) + ) + } + if applier_ids + else {} + ) return { item: { "block_id": ( anchored_blocks.get((item.investigation_id, item.anchor_id or "")) if item.anchor_type == InvestigationActionRequestAnchorType.BLOCK else None - ) + ), + "applied_by": appliers.get(str(item.applied_by_id)), } for item in item_list } @@ -129,14 +134,8 @@ def serialize( "state": obj.state, "reason": obj.reason, "action": obj.action_payload, - "appliedBy": ( - (obj.applied_details or {}).get("actor") if obj.applied_by_id is not None else None - ), - "appliedDetails": ( - {key: value for key, value in obj.applied_details.items() if key != "actor"} - if obj.applied_details is not None - else None - ), + "appliedBy": attrs["applied_by"], + "appliedDetails": obj.applied_details, "dateApplied": obj.date_applied, "error": obj.error, "dateCreated": obj.date_added, diff --git a/src/sentry/investigations/endpoints/serializers/investigation.py b/src/sentry/investigations/endpoints/serializers/investigation.py index e4a152f95cc1..096cb972fb0e 100644 --- a/src/sentry/investigations/endpoints/serializers/investigation.py +++ b/src/sentry/investigations/endpoints/serializers/investigation.py @@ -117,7 +117,8 @@ class InvestigationDetailsSerializerResponse(InvestigationSerializerResponse): """ Full notebook with ordered active blocks, parameter definitions and saved values, project IDs, and source context. source.snapshot is present only for source-backed investigations. - actionRequests has Seer's live action requests, with the primary one first. + actionRequests has Seer's action requests that aren't superseded, newest report first, with + that report's primary at the top. """ template: InvestigationTemplateSerializerResponse | None @@ -242,7 +243,7 @@ def _action_requests_by_investigation( action_requests = list( InvestigationActionRequest.objects.filter(investigation__in=item_list) .exclude(state=InvestigationActionRequestState.SUPERSEDED) - .order_by("-primary", "-report_revision", "id") + .order_by("-report_revision", "-primary", "id") ) serialized = serialize( action_requests, diff --git a/tests/sentry/investigations/endpoints/serializers/test_investigation.py b/tests/sentry/investigations/endpoints/serializers/test_investigation.py index e8da0cbdf9d5..d880dcc4e0bc 100644 --- a/tests/sentry/investigations/endpoints/serializers/test_investigation.py +++ b/tests/sentry/investigations/endpoints/serializers/test_investigation.py @@ -374,26 +374,32 @@ def test_serializes_requests_without_a_known_action(self) -> None: {"kind": "seer_task", "prompt": "Add spans"}, ] - def test_orders_the_primary_first_and_omits_superseded_requests(self) -> None: + def test_orders_the_newest_report_first_and_omits_superseded_requests(self) -> None: + old_primary = self.add_request( + "old-primary", + report_revision=0, + primary=True, + state=InvestigationActionRequestState.WITHDRAWN, + ) older = self.add_request("older", report_revision=0) self.add_request("superseded", state=InvestigationActionRequestState.SUPERSEDED) - second = self.add_request("second", report_revision=1) - first = self.add_request("first", report_revision=0, primary=True) + newer = self.add_request("newer", report_revision=1) + primary = self.add_request("primary", report_revision=1, primary=True) assert [row["id"] for row in self.serialize_requests()] == [ - str(first.id), - str(second.id), + str(primary.id), + str(newer.id), + str(old_primary.id), str(older.id), ] def test_serializes_who_applied_it_failures_and_withdrawals(self) -> None: now = timezone.now() - actor = {"id": str(self.user.id), "name": "Ada Lovelace"} self.add_request( state=InvestigationActionRequestState.APPLIED, applied_by_id=self.user.id, date_applied=now, - applied_details={"provider": "datadog", "rerun": None, "actor": actor}, + applied_details={"provider": "datadog", "rerun": None}, ) self.add_request( "failed", @@ -408,7 +414,8 @@ def test_serializes_who_applied_it_failures_and_withdrawals(self) -> None: applied, failed, withdrawn = self.serialize_requests() - assert applied["appliedBy"] == actor + assert applied["appliedBy"] is not None + assert applied["appliedBy"]["id"] == str(self.user.id) assert applied["appliedDetails"] == {"provider": "datadog", "rerun": None} assert applied["dateApplied"] == now assert failed["state"] == "failed" @@ -418,11 +425,11 @@ def test_serializes_who_applied_it_failures_and_withdrawals(self) -> None: } assert (withdrawn["state"], withdrawn["reason"]) == ("withdrawn", "The spikes are real.") - def test_hides_the_stored_applier_once_the_user_is_gone(self) -> None: + def test_shows_no_applier_once_the_user_is_gone(self) -> None: self.add_request( state=InvestigationActionRequestState.APPLIED, applied_by_id=None, - applied_details={"provider": "datadog", "rerun": None, "actor": {"id": "1"}}, + applied_details={"provider": "datadog", "rerun": None}, ) (applied,) = self.serialize_requests()