Skip to content
Draft
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
2 changes: 1 addition & 1 deletion migrations_lockfile.txt
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ nodestore: 0001_squashed_0002_nodestore_no_dictfield

notebooks: 0004_add_notebook_org_auth_token_creator

notifications: 0017_add_notificationthread_integration_id
notifications: 0018_inbox_event_idempotency

preprod: 0001_squashed_0030_add_images_errored_to_snapshot_comparison

Expand Down
23 changes: 19 additions & 4 deletions src/sentry/inbox/issue.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from uuid import NAMESPACE_URL, uuid5

from django.db.models import Q

Expand All @@ -8,6 +9,7 @@
from sentry.inbox.types import (
InboxActor,
InboxEventData,
InboxEventSummary,
InboxEventType,
InboxItemType,
)
Expand Down Expand Up @@ -109,6 +111,7 @@ def record_issue_assignment(
*,
group: Group,
assignee: Team | User | RpcUser,
activity_id: int,
assignment_source: ActivityIntegration | None,
actor: InboxActor,
) -> None:
Expand All @@ -120,7 +123,9 @@ def record_issue_assignment(
event_type.record(
subject=group,
data=IssueAssignedData(
summary="Assigned to your team" if is_team else "Assigned to you",
summary=InboxEventSummary.ISSUE_TEAM_ASSIGNED
if is_team
else InboxEventSummary.ISSUE_USER_ASSIGNED,
assignment_source=assignment_source,
),
recipient_user_ids=_get_recipient_user_ids(
Expand All @@ -129,6 +134,7 @@ def record_issue_assignment(
team_ids=[assignee.id] if is_team else [],
),
actor=actor,
idempotency_key=str(activity_id),
)


Expand All @@ -144,9 +150,12 @@ def record_issue_user_mention(

ISSUE_USER_MENTIONED.record(
subject=group,
data=IssueMentionedData(summary="Mentioned you in a comment", comment_id=comment_id),
data=IssueMentionedData(
summary=InboxEventSummary.ISSUE_USER_MENTIONED, comment_id=comment_id
),
recipient_user_ids=_get_recipient_user_ids(group, user_ids=mentioned_user_ids, team_ids=[]),
actor=actor,
idempotency_key=str(comment_id),
)


Expand All @@ -162,9 +171,12 @@ def record_issue_team_mention(

ISSUE_TEAM_MENTIONED.record(
subject=group,
data=IssueMentionedData(summary="Mentioned your team in a comment", comment_id=comment_id),
data=IssueMentionedData(
summary=InboxEventSummary.ISSUE_TEAM_MENTIONED, comment_id=comment_id
),
recipient_user_ids=_get_recipient_user_ids(group, user_ids=[], team_ids=mentioned_team_ids),
actor=actor,
idempotency_key=str(comment_id),
)


Expand All @@ -180,8 +192,11 @@ def record_issue_pr_ready_for_review(
ISSUE_PR_READY_FOR_REVIEW.record(
subject=group,
data=IssuePRReadyForReviewData(
summary="Pull request ready for review", pr_url=pr_url, seer_run_id=seer_run_id
summary=InboxEventSummary.ISSUE_PR_READY_FOR_REVIEW,
pr_url=pr_url,
seer_run_id=seer_run_id,
),
recipient_user_ids=recipients,
actor=actor,
idempotency_key=str(uuid5(NAMESPACE_URL, f"seer-run:{seer_run_id}:pr:{pr_url}")),
)
16 changes: 13 additions & 3 deletions src/sentry/inbox/notebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@

from sentry import features
from sentry.inbox.registry import inbox_item_event_type_registry, inbox_item_type_registry
from sentry.inbox.types import SYSTEM_ACTOR, InboxEventData, InboxEventType, InboxItemType
from sentry.inbox.types import (
SYSTEM_ACTOR,
InboxEventData,
InboxEventSummary,
InboxEventType,
InboxItemType,
)
from sentry.models.organization import Organization
from sentry.models.organizationmember import OrganizationMember
from sentry.notebooks.access import INVESTIGATION_SCOPES, has_investigations_access
Expand Down Expand Up @@ -74,7 +80,7 @@ class NotebookParticipantAddedData(InboxEventData):
)


def record_notebook_completed(*, notebook: Notebook, seer_run_id: int) -> None:
def record_notebook_completed(*, notebook: Notebook, seer_run_id: int, generation: int) -> None:
user_id = notebook.created_by_id
if user_id is None:
return
Expand All @@ -92,7 +98,11 @@ def record_notebook_completed(*, notebook: Notebook, seer_run_id: int) -> None:
return
NOTEBOOK_COMPLETED.record(
subject=notebook,
data=NotebookCompletedData(summary="Investigation completed", seer_run_id=seer_run_id),
data=NotebookCompletedData(
summary=InboxEventSummary.NOTEBOOK_COMPLETED,
seer_run_id=seer_run_id,
),
recipient_user_ids=[user_id],
actor=SYSTEM_ACTOR,
idempotency_key=f"{seer_run_id}:{generation}",
)
49 changes: 39 additions & 10 deletions src/sentry/inbox/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from abc import ABC, abstractmethod
from collections.abc import Iterable, Sequence
from dataclasses import asdict, dataclass
from enum import StrEnum

from django.db import router, transaction
from django.db.models.functions import Greatest
Expand Down Expand Up @@ -52,14 +53,21 @@ def get_url(self, *, organization: Organization, subject_id: str) -> str:
"""Subject URL, independent of inbox items or events."""


class InboxEventSummary(StrEnum):
ISSUE_USER_ASSIGNED = "Assigned to you"
ISSUE_TEAM_ASSIGNED = "Assigned to your team"
ISSUE_USER_MENTIONED = "Mentioned you in a comment"
ISSUE_TEAM_MENTIONED = "Mentioned your team in a comment"
ISSUE_PR_READY_FOR_REVIEW = "Pull request ready for review"
NOTEBOOK_COMPLETED = "Investigation completed"
NOTEBOOK_MENTIONED = "Mentioned you"
NOTEBOOK_TEAM_MENTIONED = "Mentioned your team"
NOTEBOOK_PARTICIPANT_ADDED = "Added you"


@dataclass(frozen=True, kw_only=True)
class InboxEventData:
"""
Stored JSON payload with a producer-supplied summary.
Add fields with defaults so existing events remain readable.
"""

summary: str
summary: InboxEventSummary


@dataclass(frozen=True, kw_only=True)
Expand All @@ -77,7 +85,9 @@ def record(
data: DataT,
recipient_user_ids: Iterable[int],
actor: InboxActor,
idempotency_key: str,
) -> None:
"""Record each key once per recipient and event type while the event is retained."""
recipients = set(recipient_user_ids)
if actor.type == InboxActorType.USER:
recipients -= {actor.id}
Expand All @@ -93,10 +103,11 @@ def record(
subject_id = self.item_type.get_subject_id(subject)
payload = asdict(data)
tags = {"event_type": self.event_type}
using = router.db_for_write(InboxItem)

with (
metrics.timer("inbox.record.duration", tags=tags),
transaction.atomic(using=router.db_for_write(InboxItem)),
transaction.atomic(using=using),
):
items = InboxItem.objects.bulk_create(
[
Expand All @@ -112,8 +123,21 @@ def record(
],
update_conflicts=True,
unique_fields=["user_id", "organization", "subject_type", "subject_id"],
update_fields=["status"],
# A no-op update locks existing items without reactivating duplicates.
update_fields=["subject_id"],
)
existing_item_ids = set(
InboxItemEvent.objects.using(using)
.filter(
inbox_item_id__in=[item.id for item in items],
type=self.event_type,
idempotency_key=idempotency_key,
)
.values_list("inbox_item_id", flat=True)
)
items = [item for item in items if item.id not in existing_item_ids]
if not items:
return
events = InboxItemEvent.objects.bulk_create(
[
InboxItemEvent(
Expand All @@ -122,13 +146,18 @@ def record(
actor_type=actor.type,
actor_id=actor.id,
data=payload,
idempotency_key=idempotency_key,
)
for item in items
]
)
for item, event in zip(items, events):
item.status = ObjectStatus.ACTIVE
item.last_activity_at = Greatest("last_activity_at", event.date_added)
item.date_updated = event.date_added
InboxItem.objects.bulk_update(items, ["last_activity_at", "date_updated"])
InboxItem.objects.bulk_update(items, ["status", "last_activity_at", "date_updated"])

metrics.incr("inbox.events.recorded", amount=len(recipients), tags=tags)
transaction.on_commit(
lambda: metrics.incr("inbox.events.recorded", amount=len(events), tags=tags),
using=using,
)
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,11 @@ def _record_completed_inbox_event(run: InvestigationOrchestrationRun) -> None:
# Remember skipped completions too: enabling recording or restoring access must
# not backfill old notifications when Seer replays a completed projection.
control["inboxCompletedGeneration"] = run.generation
record_notebook_completed(notebook=run.investigation, seer_run_id=seer_run.seer_run_state_id)
record_notebook_completed(
notebook=run.investigation,
seer_run_id=seer_run.seer_run_state_id,
generation=run.generation,
)


def _schedule_issue_links(run: InvestigationOrchestrationRun) -> None:
Expand Down
3 changes: 2 additions & 1 deletion src/sentry/models/groupassignee.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ def assign(
)
data = self.get_assigned_to_data(assigned_to, assignee_type, extra)

Activity.objects.create_group_activity(
activity = Activity.objects.create_group_activity(
group,
ActivityType.ASSIGNED,
user=acting_user,
Expand All @@ -245,6 +245,7 @@ def assign(
record_issue_assignment,
group=group,
assignee=assigned_to,
activity_id=activity.id,
assignment_source=inbox_assignment_source,
actor=InboxActor.user(acting_user.id)
if acting_user is not None
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Generated by Django 5.2.17 on 2026-11-05 23:20

from django.db import migrations, models

from sentry.new_migrations.migrations import CheckedMigration


class Migration(CheckedMigration):
# This flag is used to mark that a migration shouldn't be automatically run in production.
# This should only be used for operations where it's safe to run the migration after your
# code has deployed. So this should not be used for most operations that alter the schema
# of a table.
# Here are some things that make sense to mark as post deployment:
# - Large data migrations. Typically we want these to be run manually so that they can be
# monitored and not block the deploy for a long period of time while they run.
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
# run this outside deployments so that we don't block them. Note that while adding an index
# is a schema change, it's completely safe to run the operation after the code has deployed.
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment

is_post_deployment = False

dependencies = [
("notifications", "0017_add_notificationthread_integration_id"),
]

operations = [
migrations.AddField(
model_name="inboxitemevent",
name="idempotency_key",
field=models.CharField(max_length=64, null=True),
),
migrations.AddConstraint(
model_name="inboxitemevent",
constraint=models.UniqueConstraint(
condition=models.Q(("idempotency_key__isnull", False)),
fields=("inbox_item", "type", "idempotency_key"),
name="inboxevent_item_idempotency_uniq",
),
),
]
7 changes: 7 additions & 0 deletions src/sentry/notifications/models/inboxitemevent.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,18 @@ class InboxItemEvent(DefaultFieldsModel):
actor_id = BoundedBigIntegerField(null=True)
# Metadata specific to the event type, such as the comment to link to.
data = models.JSONField(default=dict)
# Legacy events have no key. Deduplication ends when the event is deleted.
idempotency_key = models.CharField(max_length=64, null=True)

class Meta:
app_label = "notifications"
db_table = "notifications_inboxitemevent"
constraints = [
models.UniqueConstraint(
fields=["inbox_item", "type", "idempotency_key"],
condition=Q(idempotency_key__isnull=False),
name="inboxevent_item_idempotency_uniq",
),
models.CheckConstraint(
condition=(
Q(actor_type=InboxActorType.SYSTEM.value, actor_id__isnull=True)
Expand Down
5 changes: 3 additions & 2 deletions tests/sentry/inbox/endpoints/test_organization_inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from sentry.inbox.event_retention import cleanup_inbox_item_events
from sentry.inbox.issue import ISSUE_PR_READY_FOR_REVIEW, IssuePRReadyForReviewData
from sentry.inbox.types import SYSTEM_ACTOR
from sentry.inbox.types import SYSTEM_ACTOR, InboxEventSummary
from sentry.notifications.models.inboxitem import InboxItem
from sentry.notifications.models.inboxitemevent import InboxItemEvent
from sentry.silo.base import SiloMode
Expand Down Expand Up @@ -94,9 +94,10 @@ def test_recording_works_while_ui_is_off(self) -> None:
}
):
ISSUE_PR_READY_FOR_REVIEW.record(
idempotency_key="delivery-123",
subject=self.group,
data=IssuePRReadyForReviewData(
summary="Pull request ready for review",
summary=InboxEventSummary.ISSUE_PR_READY_FOR_REVIEW,
pr_url="https://github.com/example/repo/pull/1",
seer_run_id=123,
),
Expand Down
Loading
Loading