Skip to content

Conversation

@bar-qodo
Copy link

@bar-qodo bar-qodo commented Nov 27, 2025

PR Overview by Qodo

⏱️ Less than 10 minutes ✅ ️100% Passing rules (18) 📎 85% Requirements met (4) 🛠️ Bug Fix ✨ New Feature

This PR fixes multiple bugs related to null safety and logic corrections in the Sentry codebase

Walkthroughs
Description
• Fix times_seen calculation to use maximum of two sources
• Handle null datetime in event timestamp conversion
• Prevent null pointer exception in fingerprint hashing
• Correct refund operation from increment to decrement
• Fix optional chaining in user filtering logic
• Improve default subscription reason message
Diagram
flowchart LR
 A["Bug Fixes"] --> B["Null Safety"]
 A --> C["Logic Corrections"]
 B --> D["datetime handling"]
 B --> E["fingerprint encoding"]
 B --> F["user filtering"]
 C --> G["times_seen max"]
 C --> H["refund decrement"]
 C --> I["reason message"]
Loading

Files
➀ src/sentry/api/serializers/models/group.py +1/-1

🛠️ Bug Fix

• Changed times_seen calculation to use max() function
• Now takes maximum of tagstore count and user_counts
• Ensures accurate event count reporting

src/sentry/api/serializers/models/group.py [23-45]


➁ src/sentry/event_manager.py +1/-1

🛠️ Bug Fix

• Added null check for job["event"].datetime
• Returns 0 as fallback when datetime is None
• Prevents TypeError in strftime conversion

src/sentry/event_manager.py [156-178]


➂ src/sentry/issues/ingest.py +1/-1

🛠️ Bug Fix

• Added null check for fingerprint parts
• Returns empty string if part is falsy
• Prevents md5 encoding errors on None values

src/sentry/issues/ingest.py [89-112]


➃ src/sentry/notifications/helpers.py +1/-1
✨ New Feature >
• Changed default subscription reason message
>• Updated from "are subscribed to this issue" to "subscribed"
>• Simplifies notification context text
>
>src/sentry/notifications/helpers.py [34-67]
➄ src/sentry/quotas/redis.py +1/-1

🛠️ Bug Fix

• Changed pipe.incr() to pipe.decr() for refund operation
• Corrects quota refund logic to decrement instead of increment
• Fixes incorrect quota adjustment behavior

src/sentry/quotas/redis.py [78-123]


➅ static/app/views/issueDetails/groupSidebar.tsx +2/-2
> 🛠️ Bug Fix >
• Added optional chaining operator to activeUser?.id
>• Added optional chaining to displayUsers?.length
>• Prevents null reference errors in user filtering
>
>static/app/views/issueDetails/groupSidebar.tsx [234-289]

 ⓘ Missing the old UI? Learn how to revert the configuration  

Qodo Logo

@qodo-code-review
Copy link

qodo-code-review bot commented Nov 27, 2025

Code Review by Qodo (Alpha)

Automated review surfaced additional insights for this pull request.

🐞 Bugs (3) ⚠️ Rule Violations (0) 📎 Requirements Gap (1) 💡 Suggestions (0)


Grey Divider

Issue Findings


Action Required

1. Quota Refund Logic Inverted □ 🐞 Bug
Description
The refund method changed from `pipe.incr(return_key, quantity)` to `pipe.decr(return_key,
quantity)`. This inverts the refund logic. The refund key is meant to track how many events have
been refunded, and this value is subtracted from the usage count (line 171: `int(result.value or 0)
int(refund_result.value or 0)`). By using `decr` instead of `incr`, the refund key becomes negative, which 
when subtracted from usage actually increases the reported usage instead of decreasing it. 
This breaks the quota refund mechanism entirely.
✓ Correctness
Evidence
The get_usage method at line 171 calculates usage as `usage - refund_count`. The test at line 556
expects usage to be `n - 1` after one refund. The refund key stores the count of refunded items,
which should be incremented (not decremented) when refunding. Using decr makes the refund_count
negative, causing the subtraction to become addition.
Code Reference
@@ -225,7 +225,7 @@ def refund(
            return_key = self.get_refunded_quota_key(
                self.__get_redis_key(quota, timestamp, shift, project.organization_id)
            )
-            pipe.incr(return_key, quantity)
+            pipe.decr(return_key, quantity)
            pipe.expireat(return_key, int(expiry))

src/sentry/quotas/redis.py[228]

Agent Prompt
## Issue Description
The quota refund logic has been inverted by changing from `incr` to `decr`. The refund key tracks the count of refunded events, which is subtracted from total usage. Using `decr` makes this value negative, causing refunds to increase usage instead of decrease it.

## Issue Context
When events are refunded (e.g., after being discarded due to rate limiting or errors), the system should decrease the quota usage. The refund key stores a positive count of refunded items, which is subtracted from the raw usage count in the `get_usage` method.

## Fix Focus Areas
- src/sentry/quotas/redis.py[228-228]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Incorrect Default for Notification Reason □ 🐞 Bug ✓ Correctness
Description
The default value for the notification reason changed from "are subscribed to this issue" to
"subscribed". Looking at the GroupSubscriptionReason.descriptions dictionary, all the description
values are complete sentences starting with verbs (e.g., "have commented on this issue", "have been
assigned to this issue"). The new default "subscribed" is grammatically inconsistent with the
existing descriptions and will result in malformed notification text. The descriptions are meant to
complete a sentence like "You [reason]".
Impact
Notification emails will contain grammatically incorrect text when the reason is unknown or not
found. Instead of "You are subscribed to this issue", users will see "You subscribed", which is
grammatically incorrect and inconsistent with all other notification reasons. This affects user
experience and professionalism of the notification system.
Evidence
The GroupSubscriptionReason.descriptions dictionary shows all values are complete phrases that
follow 'You' in a sentence. For example: 'have commented on this issue', 'have been assigned to this
issue', 'are subscribed to alerts for this project'. The new default 'subscribed' breaks this
pattern and creates an incomplete sentence.
Code Reference
@@ -110,7 +110,7 @@ def get_reason_context(extra_context: Mapping[str, Any]) -> MutableMapping[str,
    """Get user-specific context. Do not call get_context() here."""
    reason = extra_context.get("reason", 0)
    return {
-        "reason": GroupSubscriptionReason.descriptions.get(reason, "are subscribed to this issue")
+        "reason": GroupSubscriptionReason.descriptions.get(reason, "subscribed")
    }

src/sentry/notifications/helpers.py[112-113]

Agent Prompt
## Issue Description
The default notification reason text was changed to "subscribed", which is grammatically inconsistent with all other reason descriptions. All descriptions in GroupSubscriptionReason.descriptions are complete verb phrases that follow "You" in a sentence.

## Issue Context
The reason text is used in notification templates to explain why a user is receiving the notification. The format is "You [reason]". All existing reasons follow this pattern with complete verb phrases.

## Fix Focus Areas
- src/sentry/notifications/helpers.py[113-113]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation Recommended

3. Potential Type Error with Datetime □ 🐞 Bug ✓ Correctness
Description
The change adds a check `if job["event"].datetime else 0` to handle the case where datetime might be
None. However, the Event.datetime property is defined as returning `datetime` (not
`Optional[datetime]`), and the implementation at line 112-120 shows it will either return a datetime
object or raise an exception (e.g., KeyError if 'timestamp' is missing from data). The property
cannot return None. If datetime is missing or invalid, the code will raise an exception before
reaching the conditional check, making the None check ineffective. Additionally, returning 0 as a
fallback creates a type inconsistency - strftime returns a string, but 0 is an integer.
Impact
If the event data is malformed and missing a timestamp, the code will raise an exception (KeyError
or similar) before the None check can execute, causing event processing to fail. The None check
provides false confidence that the error is handled. Additionally, if the check somehow executes,
mixing string and integer types (strftime result vs 0) could cause type errors in downstream code
expecting a float.
Evidence
The Event.datetime property implementation shows it returns `datetime` type and will raise KeyError
if timestamp is missing from data. The property cannot return None. The conditional check `if
job["event"].datetime else 0` will never catch a None value - it will raise an exception first.
Additionally, strftime returns a string which is then converted to float, but the fallback is
integer 0, creating type inconsistency.
Code Reference
@@ -694,7 +694,7 @@ def _pull_out_data(jobs: Sequence[Job], projects: ProjectsMapping) -> None:
            set_tag(data, "transaction", transaction_name)

        job["received_timestamp"] = job["event"].data.get("received") or float(
-            job["event"].datetime.strftime("%s")
+            job["event"].datetime.strftime("%s") if job["event"].datetime else 0
        )

src/sentry/event_manager.py[696-698]

Agent Prompt
## Issue Description
A None check was added for `job["event"].datetime`, but the datetime property cannot return None - it will raise an exception if the timestamp is missing. The check provides false confidence and doesn't actually handle the error case.

## Issue Context
The Event.datetime property is defined to return `datetime` type (not Optional[datetime]). If the timestamp is missing from event data, it will raise a KeyError at line 117 before any None check can execute.

## Fix Focus Areas
- src/sentry/event_manager.py[696-698]

Consider either:
1. Removing the None check and relying on existing error handling
2. Adding proper try-except around datetime access if missing timestamps need to be handled gracefully

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Times Seen Logic May Hide Issues □ 📎 Requirements Gap ⛯ Reliability
Description
The change modifies times_seen calculation from `times_seen.get(item.id, 0)` to
`max(times_seen.get(item.id, 0), user_counts.get(item.id, 0))`. This ensures times_seen is never
less than user_count, which makes logical sense (you can't have more unique users than total
events). However, this change masks a potential data inconsistency issue. If user_counts exceeds
times_seen, it indicates a bug in the data collection or aggregation logic that should be
investigated and fixed at the source, rather than papered over with a max() call. The fix treats the
symptom rather than the root cause.
Impact
While this prevents the immediate issue of times_seen being less than user_count (which is logically
impossible), it hides underlying data quality problems. The root cause of why user_counts could
exceed times_seen remains unaddressed, and this could indicate bugs in event counting, user
tracking, or data aggregation that may affect other parts of the system. The max() call ensures data
consistency for display purposes but doesn't fix the underlying data corruption.
Evidence
The change uses max() to ensure times_seen >= user_counts, which is logically correct. However, if
this condition is violated in the data, it indicates a bug in how events or users are being counted.
The fix ensures display consistency but doesn't address why the data could be inconsistent in the
first place.
Code Reference
@@ -866,7 +866,7 @@ def __seen_stats_impl(

        return {
            item: {
-                "times_seen": times_seen.get(item.id, 0),
+                "times_seen": max(times_seen.get(item.id, 0), user_counts.get(item.id, 0)),
                "first_seen": first_seen.get(item.id),
                "last_seen": last_seen.get(item.id),

src/sentry/api/serializers/models/group.py[868-869]

Agent Prompt
## Issue Description
The max() call ensures times_seen is never less than user_count, which is logically correct. However, if this condition occurs in the data, it indicates a bug in event/user counting that should be investigated.

## Issue Context
The times_seen value comes from tagstore queries, while user_counts comes from a separate query. If user_counts > times_seen, it suggests data inconsistency in how events or users are being tracked.

## Fix Focus Areas
- src/sentry/api/serializers/models/group.py[869-869]

Consider adding:
```python
if user_counts.get(item.id, 0) > times_seen.get(item.id, 0):
   logger.warning(
       "user_count exceeds times_seen",
       extra={"item_id": item.id, "user_count": user_counts.get(item.id, 0), "times_seen": times_seen.get(item.id, 0)}
   )
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory Comments

5. Fix incorrect fingerprint hashing logic □ 💡 Suggestion ⌂ Architecture
Description
The current change uses a general falsy check (`if part`) which treats empty strings as falsy,
preventing them from being hashed. This could unintentionally alter issue grouping behavior for
fingerprints containing empty strings. The original code hashed all parts including empty strings,
and changing this behavior could cause issues that were previously grouped together to be separated,
or vice versa. A more precise check for `None` specifically would preserve the intended behavior
while still handling null values correctly.
Code Reference
def hash_fingerprint(fingerprint: list[str]) -> list[str]:
-    return [md5(part.encode("utf-8")).hexdigest() for part in fingerprint]
+    return [md5(part.encode("utf-8")).hexdigest() if part else "" for part in fingerprint]

src/sentry/issues/ingest.py[85-86]

Agent Prompt
## Issue Description
The fingerprint hashing logic uses a general falsy check that treats empty strings as falsy, which could unintentionally alter issue grouping behavior.

## Issue Context
The original code hashed all parts of the fingerprint including empty strings. The change to use `if part` prevents empty strings from being hashed, which may impact how issues are grouped together.

## Fix Focus Areas
- src/sentry/issues/ingest.py[85-86]

Consider changing to:
```python
return [
   md5(part.encode("utf-8")).hexdigest() if part is not None else "" for part in fingerprint
]
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Use correct Redis command for decrementing □ 💡 Suggestion ✓ Correctness
Description
The code uses `pipe.decr(return_key, quantity)` to decrement the quota by a specific amount.
However, the Redis `decr` command only accepts a key parameter and decrements by 1. To decrement
by a specific value, the `decrby` command should be used instead. Using `decr` with a quantity
argument may cause a TypeError in older Redis library versions or unexpected behavior, as the
quantity parameter would be ignored or misinterpreted.
Code Reference
-            pipe.incr(return_key, quantity)
+            pipe.decr(return_key, quantity)

src/sentry/quotas/redis.py[228]

Agent Prompt
## Issue Description
The code uses `pipe.decr(return_key, quantity)` but the Redis `decr` command only decrements by 1. To decrement by a specific quantity, use `decrby` instead.

## Issue Context
The refund logic needs to decrement the quota by a variable quantity. The `decr` command with a quantity parameter may cause errors or unexpected behavior.

## Fix Focus Areas
- src/sentry/quotas/redis.py[228]

Consider changing to:
```python
pipe.decrby(return_key, quantity)
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. Prevent incorrect user list filtering □ 💡 Suggestion ✓ Correctness
Description
The code uses optional chaining (`activeUser?.id`) in the filter predicate, which means when
`activeUser` is null or undefined, the filter condition becomes `null !== user.id`. Since null is
never equal to any user.id, the filter would include all users in the seenBy list when activeUser
is null. This is likely not the intended behavior - when there is no active user, the displayUsers
list should probably be empty or handle the case explicitly. The current implementation could show
incorrect user information in the UI.
Code Reference
-    const displayUsers = seenBy.filter(user => activeUser.id !== user.id);
-
-    if (!displayUsers.length) {
+    const displayUsers = seenBy.filter(user => activeUser?.id !== user.id);
+
+    if (!displayUsers?.length) {

static/app/views/issueDetails/groupSidebar.tsx[212-214]

Agent Prompt
## Issue Description
The user filtering logic uses optional chaining in a way that includes all users when `activeUser` is null, which is likely unintended behavior.

## Issue Context
When `activeUser` is null, the filter condition `null !== user.id` is always true, causing all users to be included in displayUsers. This could show incorrect user information in the sidebar.

## Fix Focus Areas
- static/app/views/issueDetails/groupSidebar.tsx[212-214]

Consider changing to:
```typescript
const displayUsers = activeUser
 ? seenBy.filter(user => activeUser.id !== user.id)
 : [];

if (!displayUsers.length) {
```

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ Did you see our little squares □ ? Resolve findings to get them checked more tips from Qodo

Grey Divider

Collected Context

Commits (3)

getsentry/sentry@a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0 getsentry/sentry@b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1 getsentry/sentry@c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2

Files
src/sentry/quotas/redis.py [1-450] src/sentry/notifications/helpers.py [1-200] src/sentry/event_manager.py [1-1200] src/sentry/api/serializers/models/group.py [1-950]
Diffs
src/sentry/quotas/redis.py [220-235] src/sentry/notifications/helpers.py [105-120] src/sentry/event_manager.py [690-705] src/sentry/api/serializers/models/group.py [860-875]
Branches (2)
⎇ main ⎇ feature/quota-refund-fix
PRs & Comments (1)
PR #54321
Tickets
Jira | SEN-8742
MCP & Custom Context
Confluence | Technical Spec

Grey Divider

Qodo Logo

self.__get_redis_key(quota, timestamp, shift, project.organization_id)
)
pipe.incr(return_key, quantity)
pipe.decr(return_key, quantity)
Copy link

@qodo-code-review qodo-code-review bot Nov 27, 2025

Choose a reason for hiding this comment

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

Action Required

1. Quota Refund Logic Inverted □ 🐞 Bug

The refund method changed from pipe.incr(return_key, quantity) to pipe.decr(return_key, quantity). This inverts the refund logic. The refund key is meant to track how many events have been refunded, and this value is subtracted from the usage count (line 171: int(result.value or 0) - int(refund_result.value or 0)). By using decr instead of incr, the refund key becomes negative, which when subtracted from usage actually increases the reported usage instead of decreasing it. This breaks the quota refund mechanism entirely.

Agent Prompt
Copy this prompt and use it to remediate the issue with your preferred AI generation tools
## Issue Description
The quota refund logic has been inverted by changing from `incr` to `decr`. The refund key tracks the count of refunded events, which is subtracted from total usage. Using `decr` makes this value negative, causing refunds to increase usage instead of decrease it.

## Issue Context
When events are refunded (e.g., after being discarded due to rate limiting or errors), the system should decrease the quota usage. The refund key stores a positive count of refunded items, which is subtracted from the raw usage count in the `get_usage` method.

## Fix Focus Areas
- src/sentry/quotas/redis.py[228-228]

reason = extra_context.get("reason", 0)
return {
"reason": GroupSubscriptionReason.descriptions.get(reason, "are subscribed to this issue")
"reason": GroupSubscriptionReason.descriptions.get(reason, "subscribed")
Copy link

@qodo-code-review qodo-code-review bot Nov 27, 2025

Choose a reason for hiding this comment

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

Action Required

2.Incorrect Default for Notification Reason □ 🐞 Bug

The default value for the notification reason changed from "are subscribed to this issue" to "subscribed". Looking at the GroupSubscriptionReason.descriptions dictionary, all the description values are complete sentences starting with verbs (e.g., "have commented on this issue", "have been assigned to this issue"). The new default "subscribed" is grammatically inconsistent with the existing descriptions and will result in malformed notification text. The descriptions are meant to complete a sentence like "You [reason]".

Agent Prompt
Copy this prompt and use it to remediate the issue with your preferred AI generation tools
## Issue Description
The default notification reason text was changed to "subscribed", which is grammatically inconsistent with all other reason descriptions. All descriptions in GroupSubscriptionReason.descriptions are complete verb phrases that follow "You" in a sentence.

## Issue Context
The reason text is used in notification templates to explain why a user is receiving the notification. The format is "You [reason]". All existing reasons follow this pattern with complete verb phrases.

## Fix Focus Areas
- src/sentry/notifications/helpers.py[113-113]


job["received_timestamp"] = job["event"].data.get("received") or float(
job["event"].datetime.strftime("%s")
job["event"].datetime.strftime("%s") if job["event"].datetime else 0
Copy link

@qodo-code-review qodo-code-review bot Nov 27, 2025

Choose a reason for hiding this comment

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

Action Required

3.Potential Type Error with Datetime □ 🐞 Bug

The change adds a check if job["event"].datetime else 0 to handle the case where datetime might be None. However, the Event.datetime property is defined as returning datetime (not Optional[datetime]), and the implementation at line 112-120 shows it will either return a datetime object or raise an exception (e.g., KeyError if 'timestamp' is missing from data). The property cannot return None. If datetime is missing or invalid, the code will raise an exception before reaching the conditional check, making the None check ineffective. Additionally, returning 0 as a fallback creates a type inconsistency - strftime returns a string, but 0 is an integer.

Agent Prompt
Copy this prompt and use it to remediate the issue with your preferred AI generation tools
## Issue Description
A None check was added for `job["event"].datetime`, but the datetime property cannot return None - it will raise an exception if the timestamp is missing. The check provides false confidence and doesn't actually handle the error case.

## Issue Context
The Event.datetime property is defined to return `datetime` type (not Optional[datetime]). If the timestamp is missing from event data, it will raise a KeyError at line 117 before any None check can execute.

## Fix Focus Areas
- src/sentry/event_manager.py[696-698]

Consider either:
1. Removing the None check and relying on existing error handling
2. Adding proper try-except around datetime access if missing timestamps need to be handled gracefully

return {
item: {
"times_seen": times_seen.get(item.id, 0),
"times_seen": max(times_seen.get(item.id, 0), user_counts.get(item.id, 0)),
Copy link

@qodo-code-review qodo-code-review bot Nov 27, 2025

Choose a reason for hiding this comment

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

Action Required

4. Times Seen Logic May Hide Issues □ 📎 Requirements Gap

The change modifies times_seen calculation from times_seen.get(item.id, 0) to max(times_seen.get(item.id, 0), user_counts.get(item.id, 0)). This ensures times_seen is never less than user_count, which makes logical sense (you can't have more unique users than total events). However, this change masks a potential data inconsistency issue. If user_counts exceeds times_seen, it indicates a bug in the data collection or aggregation logic that should be investigated and fixed at the source, rather than papered over with a max() call. The fix treats the symptom rather than the root cause.

Agent Prompt
Copy this prompt and use it to remediate the issue with your preferred AI generation tools
## Issue Description
The max() call ensures times_seen is never less than user_count, which is logically correct. However, if this condition occurs in the data, it indicates a bug in event/user counting that should be investigated.

## Issue Context
The times_seen value comes from tagstore queries, while user_counts comes from a separate query. If user_counts > times_seen, it suggests data inconsistency in how events or users are being tracked.

## Fix Focus Areas
- src/sentry/api/serializers/models/group.py[869-869]

Consider adding:
```python
if user_counts.get(item.id, 0) > times_seen.get(item.id, 0):
    logger.warning(
        "user_count exceeds times_seen",
        extra={"item_id": item.id, "user_count": user_counts.get(item.id, 0), "times_seen": times_seen.get(item.id, 0)}
    )
```

coderabbitai[bot]

This comment was marked as spam.

@qodo-benchmark qodo-benchmark deleted a comment from coderabbitai bot Nov 27, 2025
@qodo-benchmark qodo-benchmark deleted a comment from bar-qodo Nov 27, 2025
@qodo-benchmark qodo-benchmark deleted a comment from qodo-code-review bot Nov 27, 2025
@qodo-benchmark qodo-benchmark deleted a comment from qodo-code-review bot Nov 27, 2025
@qodo-benchmark qodo-benchmark deleted a comment from sentry bot Nov 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants