Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 10 additions & 18 deletions src/sentry/api/helpers/group_index/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from sentry.analytics.events.manual_issue_assignment import ManualIssueAssignment
from sentry.api.serializers import serialize
from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse
from sentry.api.serializers.models.groupactionlogentry import serialize_first_seen_entry
from sentry.api.serializers.models.groupactionlogentry import get_serialized_activity_items
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 (
Expand All @@ -32,12 +32,11 @@
resolve_action_actor,
resolve_action_source,
)
from sentry.issues.action_log.read_metrics import activity_read_endpoint
from sentry.issues.action_log.types import MergeIntoOtherAction
from sentry.issues.derived.gate import should_serve_action_log_activity
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
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
from sentry.issues.priority import update_priority
from sentry.issues.status_change import handle_status_update, infer_substatus
from sentry.issues.update_inbox import update_inbox
Expand Down Expand Up @@ -784,21 +783,14 @@ def prepare_response(
if len(group_list) == 1:
if res_type in (GroupResolution.Type.in_next_release, GroupResolution.Type.in_release):
group = group_list[0]
if should_serve_action_log_activity(group.project, acting_user):
action_log = GroupActionLogEntry.objects.get_actions_for_group(
group, ACTIVITIES_COUNT - 1
)
if action_log:
result["activity"] = [
*serialize(action_log, acting_user),
serialize_first_seen_entry(group),
]
else:
logger.info(
"group_index.groupactionlogentry.not_found",
extra={"group_id": group.id},
)

activity_items = get_serialized_activity_items(
group,
acting_user,
endpoint=activity_read_endpoint(request),
limit=ACTIVITIES_COUNT - 1,
)
if activity_items is not None:
result["activity"] = activity_items
else:
result["activity"] = serialize(
Activity.objects.get_activities_for_group(
Expand Down
39 changes: 39 additions & 0 deletions src/sentry/api/serializers/models/groupactionlogentry.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, TypedDict
Expand All @@ -7,6 +8,11 @@
from sentry.api.serializers import Serializer, register, serialize
from sentry.api.serializers.models.activity import _ActivitySentryAppEmbed
from sentry.api.serializers.models.commit import CommitWithReleaseSerializer
from sentry.issues.action_log.read_metrics import (
ActivityReadReason,
ActivityReadResult,
record_activity_read,
)
from sentry.issues.action_log.types import (
ACTION_TYPES_WITH_COMMIT_DATA,
COMMIT_ACTION_TYPES,
Expand All @@ -15,6 +21,7 @@
GroupActionType,
GroupActorType,
)
from sentry.issues.derived.gate import should_serve_action_log_activity
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
from sentry.models.commit import Commit
from sentry.models.pullrequest import PullRequest
Expand All @@ -35,6 +42,8 @@
if TYPE_CHECKING:
from sentry.models.group import Group

logger = logging.getLogger(__name__)


class GroupActionLogEntrySerializerResponse(TypedDict):
id: str
Expand Down Expand Up @@ -83,6 +92,36 @@ def _serialized_id(obj: GroupActionLogEntry) -> str:
return str(obj.id)


def get_serialized_activity_items(
group: "Group",
user: User | RpcUser | AnonymousUser | None,
*,
endpoint: str,
limit: int = 99,
) -> list[Any] | None:
"""
Activity-shaped items for a group, read from the action log.

Returns None when the log can't back the response — either the gate is closed or it's
open and the log is empty — and the caller should fall back to Activity. Reports the
read outcome in both cases, so callers don't have to.
"""
if not should_serve_action_log_activity(group.project, user, endpoint=endpoint):
return None

action_log = GroupActionLogEntry.objects.get_actions_for_group(group, limit)
if not action_log:
record_activity_read(endpoint, ActivityReadResult.FELL_BACK, ActivityReadReason.EMPTY_LOG)
logger.info(
"issues.action_log.activity_read.not_found",
extra={"endpoint": endpoint, "group_id": group.id},
)
return None

record_activity_read(endpoint, ActivityReadResult.GALE)
return [*serialize(action_log, user), serialize_first_seen_entry(group)]


@register(GroupActionLogEntry)
class GroupActionLogEntrySerializer(Serializer):
def get_attrs(
Expand Down
47 changes: 47 additions & 0 deletions src/sentry/issues/action_log/read_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from enum import StrEnum

from rest_framework.request import Request

from sentry.utils import metrics

ACTIVITY_READ_METRIC = "issues.action_log.activity_read"


class ActivityReadResult(StrEnum):
#: The log backed the response.
GALE = "gale"
#: The gate was open, or the flag was on, but we served Activity anyway.
FELL_BACK = "fell_back"
#: The read flag is off for this project, so the log was never consulted.
FLAG_OFF = "flag_off"


class ActivityReadReason(StrEnum):
#: The project is enrolled but its backfill hasn't finished.
NOT_BACKFILLED = "not_backfilled"
#: The gate was open and the log came back empty.
EMPTY_LOG = "empty_log"


def activity_read_endpoint(request: Request) -> str:
"""The route being served, for the `endpoint` tag."""
if request.resolver_match is None or request.resolver_match.url_name is None:
return "unknown"
return request.resolver_match.url_name


def record_activity_read(
endpoint: str,
result: ActivityReadResult,
reason: ActivityReadReason | None = None,
) -> None:
"""
Record the outcome of one attempt to serve activity from the action log.

Called once per read: by ``should_serve_action_log_activity`` when the gate closes,
otherwise by the caller once it knows whether the read produced anything.
"""
tags = {"endpoint": endpoint, "result": result.value}
if reason is not None:
tags["reason"] = reason.value
metrics.incr(ACTIVITY_READ_METRIC, sample_rate=1.0, tags=tags)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this allow us to count those without a reason directly using 'N/A' or something? I can't recall.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, i think missing tags are countable

29 changes: 25 additions & 4 deletions src/sentry/issues/derived/gate.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
from django.contrib.auth.models import AnonymousUser

from sentry import features
from sentry.issues.action_log.read_metrics import (
ActivityReadReason,
ActivityReadResult,
record_activity_read,
)
from sentry.models.options.project_option import ProjectOption
from sentry.models.project import Project
from sentry.users.models.user import User
Expand Down Expand Up @@ -29,8 +34,24 @@ def derived_should_be_correct(project: Project) -> bool:
def should_serve_action_log_activity(
project: Project,
actor: User | RpcUser | AnonymousUser | None = None,
*,
endpoint: str,
) -> bool:
"""Whether the action log can back this project's Activity-shaped responses."""
return features.has(
"projects:issue-action-log-activity", project, actor=actor
) and derived_should_be_correct(project)
"""
Whether the action log can back this project's Activity-shaped responses.

Records the read outcome itself when it returns False, because only it knows which
condition closed the gate. Returning True records nothing: the caller goes on to read
the log, so the caller reports whether that produced anything.
"""
if not features.has("projects:issue-action-log-activity", project, actor=actor):
record_activity_read(endpoint, ActivityReadResult.FLAG_OFF)
return False

if not derived_should_be_correct(project):
record_activity_read(
endpoint, ActivityReadResult.FELL_BACK, ActivityReadReason.NOT_BACKFILLED
)
return False

return True
23 changes: 7 additions & 16 deletions src/sentry/issues/endpoints/group_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
from sentry.api.base import cell_silo_endpoint
from sentry.api.helpers.deprecation import deprecated
from sentry.api.serializers import serialize
from sentry.api.serializers.models.groupactionlogentry import serialize_first_seen_entry
from sentry.api.serializers.models.groupactionlogentry import get_serialized_activity_items
from sentry.constants import CELL_API_DEPRECATION_DATE
from sentry.issues.derived.gate import should_serve_action_log_activity
from sentry.issues.action_log.read_metrics import activity_read_endpoint
from sentry.issues.endpoints.bases.group import GroupEndpoint
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
from sentry.models.activity import Activity
from sentry.models.group import Group

Expand All @@ -33,19 +32,11 @@ def get(self, request: Request, group: Group) -> Response:
"""
Retrieve all the Activities for a Group
"""
if should_serve_action_log_activity(group.project, request.user):
action_log = GroupActionLogEntry.objects.get_actions_for_group(group, 99)
if action_log:
serialized = serialize(action_log, request.user)
serialized.append(serialize_first_seen_entry(group))
return Response(
{
"activity": serialized,
}
)
logger.info(
"group_activities.groupactionlogentry.not_found", extra={"group_id": group.id}
)
activity_items = get_serialized_activity_items(
group, request.user, endpoint=activity_read_endpoint(request)
)
if activity_items is not None:
return Response({"activity": activity_items})

activity = Activity.objects.get_activities_for_group(group, num=100)
return Response(
Expand Down
24 changes: 9 additions & 15 deletions src/sentry/issues/endpoints/group_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from sentry.api.helpers.group_index.validators import GroupValidator
from sentry.api.serializers import GroupSerializer, GroupSerializerSnuba, serialize
from sentry.api.serializers.models.group import BaseGroupSerializerResponse, GroupDetailsResponse
from sentry.api.serializers.models.groupactionlogentry import serialize_first_seen_entry
from sentry.api.serializers.models.groupactionlogentry import get_serialized_activity_items
from sentry.apidocs.constants import (
RESPONSE_ACCEPTED,
RESPONSE_BAD_REQUEST,
Expand All @@ -47,17 +47,17 @@
resolve_action_actor,
resolve_action_source,
)
from sentry.issues.action_log.read_metrics import activity_read_endpoint
from sentry.issues.action_log.types import ViewAction
from sentry.issues.constants import (
ISSUE_VIEW_CACHE_KEY_TTL,
cache_key_for_issue_view,
get_issue_tsdb_group_model,
)
from sentry.issues.derived.check import record_status_consistency
from sentry.issues.derived.gate import derived_should_be_correct, should_serve_action_log_activity
from sentry.issues.derived.gate import derived_should_be_correct
from sentry.issues.endpoints.bases.group import GroupEndpoint
from sentry.issues.escalating.escalating_group_forecast import EscalatingGroupForecast
from sentry.issues.models.groupactionlogentry import GroupActionLogEntry
from sentry.issues.models.groupderiveddata import GroupDerivedData
from sentry.models.activity import Activity
from sentry.models.eventattachment import EventAttachment
Expand Down Expand Up @@ -342,18 +342,12 @@ def get(self, request: Request, group: Group) -> Response[GroupDetailsResponse]:
}
)

if should_serve_action_log_activity(group.project, request.user):
action_log = GroupActionLogEntry.objects.get_actions_for_group(group, 99)
if action_log:
# swap action log data in under the activity name
first_seen_entry = cast(dict[str, Any], serialize_first_seen_entry(group))
data.update(
{"activity": [*serialize(action_log, request.user), first_seen_entry]}
)
else:
logger.info(
"group_details.groupactionlogentry.not_found", extra={"group_id": group.id}
)
# swap action log data in under the activity name
activity_items = get_serialized_activity_items(
group, request.user, endpoint=activity_read_endpoint(request)
)
if activity_items is not None:
data.update({"activity": activity_items})

if "stats" not in collapse:
hourly_stats, daily_stats = self.__group_hourly_daily_stats(group, environment_ids)
Expand Down
21 changes: 19 additions & 2 deletions src/sentry/issues/endpoints/group_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
from sentry.apidocs.utils import inline_sentry_response_serializer
from sentry.constants import CELL_API_DEPRECATION_DATE
from sentry.issues.action_log import action_context_scope, resolve_action_source
from sentry.issues.action_log.read_metrics import (
ActivityReadReason,
ActivityReadResult,
activity_read_endpoint,
record_activity_read,
)
from sentry.issues.action_log.types import (
CommentDeleteAction,
CommentEditAction,
Expand Down Expand Up @@ -58,7 +64,11 @@ class GroupNotesEndpoint(GroupEndpoint):
url_names=["sentry-api-0-group-notes"],
)
def get(self, request: Request, group: Group) -> Response:
if should_serve_action_log_activity(group.project, request.user):
endpoint = activity_read_endpoint(request)
if should_serve_action_log_activity(group.project, request.user, endpoint=endpoint):
# No empty-log fallback on this path: once the gate is open the log is
# authoritative for comments, including when the group has none.
record_activity_read(endpoint, ActivityReadResult.GALE)
edit_entries = GroupActionLogEntry.objects.filter(
group_id=group.id, type=GroupActionType.COMMENT_EDIT.value
).order_by("-date_added", "-id")
Expand Down Expand Up @@ -187,13 +197,20 @@ def post(self, request: Request, group: Group) -> Response:
sender="post",
)

if should_serve_action_log_activity(group.project, request.user):
endpoint = activity_read_endpoint(request)
if should_serve_action_log_activity(group.project, request.user, endpoint=endpoint):
entry = GroupActionLogEntry.objects.filter(
group_id=group.id,
idempotency_key=activity_action_idempotency_key(activity),
).first()
if entry:
record_activity_read(endpoint, ActivityReadResult.GALE)
return Response(serialize(entry, request.user), status=201)
record_activity_read(
endpoint,
ActivityReadResult.FELL_BACK,
ActivityReadReason.EMPTY_LOG,
)
logger.info("group_notes.groupactionlogentry.not_found", extra={"group_id": group.id})

return Response(serialize(activity, request.user), status=201)
Loading
Loading