Skip to content

feat: Expose last seen and times seen in the discarded issues table #93694

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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: 2 additions & 0 deletions src/sentry/api/serializers/models/grouptombstone.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ def serialize(self, obj, attrs, user, **kwargs):
"type": obj.get_event_type(),
"metadata": obj.get_event_metadata(),
"actor": attrs.get("user"),
"timesSeen": obj.times_seen,
"lastSeen": obj.last_seen,
}
32 changes: 31 additions & 1 deletion src/sentry/event_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,32 @@ def get_stored_crashreports(cache_key: str | None, event: Event, max_crashreport
return query[:max_crashreports].count()


def increment_group_tombstone_hit_counter(tombstone_id: int | None, event: Event) -> None:
if tombstone_id is None:
return
try:
from sentry.models.grouptombstone import GroupTombstone

group_tombstone = GroupTombstone.objects.get(id=tombstone_id)
buffer_incr(
GroupTombstone,
{"times_seen": 1},
{"id": tombstone_id},
{
"last_seen": (
max(event.datetime, group_tombstone.last_seen)
if group_tombstone.last_seen
else event.datetime
)
},
)
except GroupTombstone.DoesNotExist:
# This can happen due to a race condition with deletion.
pass
except Exception:
logger.exception("Failed to update GroupTombstone count for id: %s", tombstone_id)


ProjectsMapping = Mapping[int, Project]

Job = MutableMapping[str, Any]
Expand Down Expand Up @@ -510,7 +536,11 @@ def save_error_events(
try:
group_info = assign_event_to_group(event=job["event"], job=job, metric_tags=metric_tags)

except HashDiscarded:
except HashDiscarded as e:
if features.has("organizations:grouptombstones-hit-counter", project.organization):
increment_group_tombstone_hit_counter(
getattr(e, "tombstone_id", None), job["event"]
)
discard_event(job, attachments)
raise

Expand Down
2 changes: 2 additions & 0 deletions static/app/types/group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,8 @@ export interface GroupTombstone {
level: Level;
metadata: EventMetadata;
type: EventOrGroupType;
lastSeen?: string;
timesSeen?: number;
title?: string;
}
export interface GroupTombstoneHelper extends GroupTombstone {
Expand Down
145 changes: 134 additions & 11 deletions static/app/views/settings/project/projectFilters/groupTombstones.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Access from 'sentry/components/acl/access';
import Confirm from 'sentry/components/confirm';
import {UserAvatar} from 'sentry/components/core/avatar/userAvatar';
import {Button} from 'sentry/components/core/button';
import Count from 'sentry/components/count';
import EmptyMessage from 'sentry/components/emptyMessage';
import ErrorBoundary from 'sentry/components/errorBoundary';
import EventOrGroupHeader from 'sentry/components/eventOrGroupHeader';
Expand All @@ -14,11 +15,15 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
import Pagination from 'sentry/components/pagination';
import Panel from 'sentry/components/panels/panel';
import PanelItem from 'sentry/components/panels/panelItem';
import {PanelTable} from 'sentry/components/panels/panelTable';
import TimeSince from 'sentry/components/timeSince';
import {IconDelete} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {GroupTombstone} from 'sentry/types/group';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
import {defined} from 'sentry/utils';
import {useApiQuery} from 'sentry/utils/queryClient';
import useApi from 'sentry/utils/useApi';
import {useLocation} from 'sentry/utils/useLocation';
Expand All @@ -30,9 +35,77 @@ interface GroupTombstoneRowProps {
onUndiscard: (id: string) => void;
}

function hasGrouptombstonesHitCounter(organization: Organization) {
return organization.features.includes('grouptombstones-hit-counter');
}

function GroupTombstoneRow({data, disabled, onUndiscard}: GroupTombstoneRowProps) {
const organization = useOrganization();

const actor = data.actor;

if (hasGrouptombstonesHitCounter(organization)) {
return (
<Fragment>
<StyledBox>
<EventOrGroupHeader
hideIcons
data={{...data, isTombstone: true}}
source="group-tombstome"
/>
</StyledBox>
<RightAlignedColumn>
{data.lastSeen ? (
<TimeSince
date={data.lastSeen}
unitStyle="short"
suffix="ago"
disabledAbsoluteTooltip
/>
) : (
'-'
)}
</RightAlignedColumn>
<RightAlignedColumn>
{defined(data.timesSeen) ? <Count value={data.timesSeen} /> : '-'}
</RightAlignedColumn>
<CenteredAlignedColumn>
{actor ? (
<UserAvatar
user={actor}
hasTooltip
tooltip={t('Discarded by %s', actor.name || actor.email)}
/>
) : (
'-'
)}
</CenteredAlignedColumn>
<CenteredAlignedColumn>
<Confirm
message={t(
'Undiscarding this issue means that incoming events that match this will no longer be discarded. New incoming events will count toward your event quota and will display on your issues dashboard. Are you sure you wish to continue?'
)}
onConfirm={() => onUndiscard(data.id)}
disabled={disabled}
>
<Button
type="button"
aria-label={t('Undiscard')}
title={
disabled
? t('You do not have permission to perform this action')
: t('Undiscard')
}
size="sm"
icon={<IconDelete />}
disabled={disabled}
/>
</Confirm>
</CenteredAlignedColumn>
</Fragment>
);
}

return (
<PanelItem center>
<StyledBox>
Expand Down Expand Up @@ -131,7 +204,7 @@ function GroupTombstones({project}: GroupTombstonesProps) {
return <LoadingError onRetry={refetch} />;
}

if (!tombstones?.length) {
if (!tombstones?.length && !hasGrouptombstonesHitCounter(organization)) {
return (
<Panel>
<EmptyMessage>{t('You have no discarded issues')}</EmptyMessage>
Expand All @@ -144,16 +217,43 @@ function GroupTombstones({project}: GroupTombstonesProps) {
<Access access={['project:write']} project={project}>
{({hasAccess}) => (
<Fragment>
<Panel>
{tombstones.map(data => (
<GroupTombstoneRow
key={data.id}
data={data}
disabled={!hasAccess}
onUndiscard={handleUndiscard}
/>
))}
</Panel>
{hasGrouptombstonesHitCounter(organization) ? (
<StyledPanelTable
headers={[
<LeftAlignedColumn key="lastSeen">{t('Issue')}</LeftAlignedColumn>,
<RightAlignedColumn key="lastSeen">
{t('Last Seen')}
</RightAlignedColumn>,
<RightAlignedColumn key="events">{t('Events')}</RightAlignedColumn>,
<CenteredAlignedColumn key="member">
{t('Member')}
</CenteredAlignedColumn>,
<CenteredAlignedColumn key="actions" />,
]}
isEmpty={!tombstones.length}
emptyMessage={t('You have no discarded issues')}
>
{tombstones.map(data => (
<GroupTombstoneRow
key={data.id}
data={data}
disabled={!hasAccess}
onUndiscard={handleUndiscard}
/>
))}
</StyledPanelTable>
) : (
<Panel>
{tombstones.map(data => (
<GroupTombstoneRow
key={data.id}
data={data}
disabled={!hasAccess}
onUndiscard={handleUndiscard}
/>
))}
</Panel>
)}
{tombstonesPageLinks && <Pagination pageLinks={tombstonesPageLinks} />}
</Fragment>
)}
Expand All @@ -168,6 +268,29 @@ const StyledBox = styled('div')`
min-width: 0; /* keep child content from stretching flex item */
`;

const StyledPanelTable = styled(PanelTable)`
grid-template-columns:
minmax(220px, 1fr)
max-content max-content max-content max-content;
`;

const Column = styled('div')`
display: flex;
align-items: center;
`;

const RightAlignedColumn = styled(Column)`
justify-content: flex-end;
`;

const LeftAlignedColumn = styled(Column)`
justify-content: flex-start;
`;

const CenteredAlignedColumn = styled(Column)`
justify-content: center;
`;

const AvatarContainer = styled('div')`
margin: 0 ${space(3)};
flex-shrink: 1;
Expand Down
58 changes: 35 additions & 23 deletions tests/sentry/event_manager/test_event_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2117,39 +2117,51 @@ def test_throws_when_matches_discarded_hash(self) -> None:
)
GroupHash.objects.filter(group=group).update(group=None, group_tombstone_id=tombstone.id)

manager = EventManager(
make_event(message="foo", event_id="b" * 32, fingerprint=["a" * 32]),
project=self.project,
)
manager.normalize()
from sentry.utils.outcomes import track_outcome

a1 = CachedAttachment(name="a1", data=b"hello")
a2 = CachedAttachment(name="a2", data=b"world")

cache_key = cache_key_for_event(manager.get_data())
attachment_cache.set(cache_key, attachments=[a1, a2])
for i, event_id in enumerate(["b" * 32, "c" * 32]):
manager = EventManager(
make_event(message="foo", event_id=event_id, fingerprint=["a" * 32]),
project=self.project,
)
manager.normalize()
discarded_event = Event(
project_id=self.project.id, event_id=event_id, data=manager.get_data()
)

from sentry.utils.outcomes import track_outcome
cache_key = cache_key_for_event(manager.get_data())
attachment_cache.set(cache_key, attachments=[a1, a2])

mock_track_outcome = mock.Mock(wraps=track_outcome)
with mock.patch("sentry.event_manager.track_outcome", mock_track_outcome):
with self.feature("organizations:event-attachments"):
with self.tasks():
with pytest.raises(HashDiscarded):
manager.save(self.project.id, cache_key=cache_key, has_attachments=True)
mock_track_outcome = mock.Mock(wraps=track_outcome)
with (
mock.patch("sentry.event_manager.track_outcome", mock_track_outcome),
self.feature("organizations:event-attachments"),
self.feature("organizations:grouptombstones-hit-counter"),
self.tasks(),
pytest.raises(HashDiscarded),
):
manager.save(self.project.id, cache_key=cache_key, has_attachments=True)

assert mock_track_outcome.call_count == 3
assert mock_track_outcome.call_count == 3

for o in mock_track_outcome.mock_calls:
assert o.kwargs["outcome"] == Outcome.FILTERED
assert o.kwargs["reason"] == FilterStatKeys.DISCARDED_HASH
event_outcome = mock_track_outcome.mock_calls[0].kwargs
assert event_outcome["outcome"] == Outcome.FILTERED
assert event_outcome["reason"] == FilterStatKeys.DISCARDED_HASH
assert event_outcome["category"] == DataCategory.ERROR
assert event_outcome["event_id"] == event_id

o = mock_track_outcome.mock_calls[0]
assert o.kwargs["category"] == DataCategory.ERROR
for call in mock_track_outcome.mock_calls[1:]:
attachment_outcome = call.kwargs
assert attachment_outcome["category"] == DataCategory.ATTACHMENT
assert attachment_outcome["quantity"] == 5

for o in mock_track_outcome.mock_calls[1:]:
assert o.kwargs["category"] == DataCategory.ATTACHMENT
assert o.kwargs["quantity"] == 5
expected_times_seen = 1 + i
tombstone.refresh_from_db()
assert tombstone.times_seen == expected_times_seen
assert tombstone.last_seen == discarded_event.datetime

def test_honors_crash_report_limit(self) -> None:
from sentry.utils.outcomes import track_outcome
Expand Down
Loading