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
10 changes: 10 additions & 0 deletions src/sentry/issues/formatting/limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,13 @@ class Limits:
max_spans_chars=5_000,
max_contexts_chars=5_000,
)

# tighter caps for token-constrained callers; mirrors Seer's EVENT_FORMAT_LIMITS_LOW
LIMITS_LOW = Limits(
max_exceptions_chars=50_000,
max_stacktrace_chars=10_000,
max_breadcrumbs_chars=5_000,
max_single_breadcrumb_chars=500,
max_request_chars=2_000,
max_spans_chars=5_000,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low limits uncap contexts

Medium Severity

LIMITS_LOW omits max_contexts_chars, so format_limits="low" leaves the Contexts section uncapped while format_limits="default" truncates at 5,000 characters. Token-constrained RPC callers can get larger formatted output under the low profile than under default on context-heavy events.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d82bf51. Configure here.

Comment on lines +33 to +40

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The LIMITS_LOW constant omits max_contexts_chars, causing it to default to None. This results in no character limit for the contexts section, contrary to its intended purpose.
Severity: MEDIUM

Suggested Fix

Add the max_contexts_chars field to the LIMITS_LOW constant in src/sentry/issues/formatting/limits.py. Set its value to be equal to or less than the corresponding value in LIMITS_DEFAULT to ensure it provides a stricter cap.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: src/sentry/issues/formatting/limits.py#L33-L40

Potential issue: The `LIMITS_LOW` constant, intended to provide tighter character caps,
omits the `max_contexts_chars` field. This causes the value to default to `None`. When
formatting an issue's context section with `format_limits="low"`, the `_truncate`
function receives `None` and does not apply any character limit. This results in
unbounded output for the contexts section, which is contrary to the feature's goal of
providing a more restrictive limit profile than the default. This can cause issues for
token-constrained callers, such as LLMs, which expect a capped output size.

Also affects:

  • src/sentry/issues/formatting/sections.py:166-170
  • src/sentry/seer/agent/tools.py

Did we get this right? 👍 / 👎 to inform future reviews.

33 changes: 31 additions & 2 deletions src/sentry/seer/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import time
import uuid
from datetime import UTC, datetime, timedelta, timezone
from typing import Any, TypedDict, cast
from typing import Any, Literal, TypedDict, cast

from django.core.exceptions import BadRequest
from django.db import models
Expand All @@ -27,6 +27,14 @@
from sentry.api.utils import MAX_STATS_PERIOD, default_start_end_dates, get_date_range_from_params
from sentry.constants import ALL_ACCESS_PROJECT_ID, ObjectStatus
from sentry.exceptions import InvalidParams, InvalidSearchQuery
from sentry.issues.formatting.formatter import Format
from sentry.issues.formatting.limits import LIMITS_DEFAULT, LIMITS_LOW
from sentry.issues.formatting.mixin import FORMATTER_FEATURE
from sentry.issues.formatting.sections import (
EVENT_SECTIONS,
breadcrumbs_section,
format_issue,
)
from sentry.issues.grouptype import GroupCategory
from sentry.models.activity import Activity
from sentry.models.apikey import ApiKey
Expand Down Expand Up @@ -2060,6 +2068,9 @@ def get_event_details(
start: str | None = None,
end: str | None = None,
project_slug: str | None = None,
format: Format | None = None,
format_limits: Literal["default", "low"] = "default",
include_breadcrumbs: bool = True,
) -> EventDetailsResponse | None:
"""
Get event details by event ID, or get the recommended event for an issue, optionally scoped by time range.
Expand All @@ -2072,9 +2083,14 @@ def get_event_details(
start: ISO timestamp for the start of the time range to get recommended event for (optional).
end: ISO timestamp for the end of the time range to get recommended event for (optional).
project_slug: The slug of the project (optional).
format: When set (markdown | xml), also render the event through the shared formatter into
the ``formatted`` field. Requires the ``organizations:issue-standardized-markdown-for-llm`` feature.
format_limits: Truncation profile for the rendered output ("default" or "low").
include_breadcrumbs: Drop the breadcrumbs section from the rendered output when False.

Returns:
Dict with serialized event, event_id, event_trace_id, project_id, project_slug, or None if not found.
Dict with serialized event, event_id, event_trace_id, project_id, project_slug, and
formatted (rendered text when a ``format`` is requested, else None), or None if not found.
"""
if bool(event_id) == bool(issue_id):
raise BadRequest("Either event_id or issue_id must be provided, but not both.")
Expand Down Expand Up @@ -2161,12 +2177,25 @@ def get_event_details(
serialized_event = dict(serialize(event, user=None, serializer=EventSerializer()))
serialized_event.update(_get_event_troubleshooting_context(event))

# Opt-in shared-formatter output for Seer, gated behind the rollout feature so it can be
# ramped gradually; when the feature is off, callers fall back to their own formatter.
formatted: str | None = None
if format is not None and features.has(FORMATTER_FEATURE, organization):
limits = LIMITS_LOW if format_limits == "low" else LIMITS_DEFAULT
sections = (
EVENT_SECTIONS
if include_breadcrumbs
else [section for section in EVENT_SECTIONS if section is not breadcrumbs_section]
)
formatted = format_issue(serialized_event, format=format, sections=sections, limits=limits)

return EventDetailsResponse(
event=serialized_event,
event_id=event.event_id,
event_trace_id=event.trace_id,
project_id=event.project_id,
project_slug=event.project.slug,
formatted=formatted,
)


Expand Down
2 changes: 2 additions & 0 deletions src/sentry/seer/sentry_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ class EventDetailsResponse(_DictProxyMixin):
event_trace_id: str | None
project_id: int
project_slug: str
# shared-formatter text; populated when the RPC is called with a `format`, else None
formatted: str | None = None


class IssueDetailsResponse(_DictProxyMixin):
Expand Down
69 changes: 69 additions & 0 deletions tests/sentry/seer/agent/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2579,6 +2579,75 @@ def test_by_event_id_single_project(self) -> None:

self._assert_event_response_shape(result, expected_event_id=event.event_id)

def test_format_returns_shared_formatter_output(self) -> None:
event = self._make_error_event()

with self.feature("organizations:issue-standardized-markdown-for-llm"):
result = get_event_details(
organization_id=self.organization.id,
event_id=event.event_id,
project_slug=self.project.slug,
format="markdown",
)

assert result is not None
assert result["formatted"] is not None
assert "## Title" in result["formatted"]
assert "## Exception" in result["formatted"]

def test_format_omitted_when_option_disabled(self) -> None:
# format requested, but the rollout option is off -> no formatted (caller falls back)
event = self._make_error_event()

result = get_event_details(
organization_id=self.organization.id,
event_id=event.event_id,
project_slug=self.project.slug,
format="markdown",
)

assert result is not None
assert result["formatted"] is None

def test_no_format_omits_formatted(self) -> None:
event = self._make_error_event()

result = get_event_details(
organization_id=self.organization.id,
event_id=event.event_id,
project_slug=self.project.slug,
)

assert result is not None
assert result["formatted"] is None

def test_include_breadcrumbs_false_drops_section(self) -> None:
data = load_data("python", timestamp=before_now(minutes=5))
data["breadcrumbs"] = {
"values": [{"category": "auth", "message": "login", "level": "info"}]
}
event = self.store_event(data=data, project_id=self.project.id)

with self.feature("organizations:issue-standardized-markdown-for-llm"):
with_crumbs = get_event_details(
organization_id=self.organization.id,
event_id=event.event_id,
project_slug=self.project.slug,
format="markdown",
)
without_crumbs = get_event_details(
organization_id=self.organization.id,
event_id=event.event_id,
project_slug=self.project.slug,
format="markdown",
include_breadcrumbs=False,
)

assert with_crumbs is not None and with_crumbs["formatted"] is not None
assert "## Breadcrumbs" in with_crumbs["formatted"]
assert without_crumbs is not None and without_crumbs["formatted"] is not None
assert "## Breadcrumbs" not in without_crumbs["formatted"]

def test_by_event_id_multi_project(self) -> None:
"""Fetching by event_id without project_slug hits the multi-project code path."""
self.create_project(organization=self.organization) # second project → multi-project path
Expand Down
51 changes: 50 additions & 1 deletion tests/sentry/seer/endpoints/test_organization_seer_rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
from sentry.models.apitoken import ApiToken
from sentry.models.project import Project
from sentry.silo.base import SiloMode
from sentry.testutils.cases import APITestCase
from sentry.testutils.cases import APITestCase, SnubaTestCase
from sentry.testutils.helpers.datetime import before_now
from sentry.testutils.helpers.features import with_feature
from sentry.testutils.silo import assume_test_silo_mode
from sentry.utils.samples import load_data


class TestOrganizationSeerRpcEndpoint(APITestCase):
Expand Down Expand Up @@ -400,3 +402,50 @@ def test_has_repo_code_mappings(self) -> None:

assert response.status_code == 200
assert response.data == {"has_code_mappings": False, "project_slug_to_id": {}}


class TestOrganizationSeerRpcGetEventDetailsWire(APITestCase, SnubaTestCase):
"""End-to-end wire check: get_event_details' `formatted` field survives serialization
through the RPC endpoint and comes back in the JSON body."""

endpoint = "sentry-api-0-organization-seer-rpc"

def setUp(self) -> None:
super().setUp()
self.organization = self.create_organization(owner=self.user)
self.project = self.create_project(organization=self.organization)
self.login_as(self.user)

def _get_path(self, method_name: str) -> str:
return reverse(
self.endpoint,
kwargs={
"organization_id_or_slug": self.organization.slug,
"method_name": method_name,
},
)

@with_feature("organizations:seer-public-rpc")
@with_feature("organizations:issue-standardized-markdown-for-llm")
def test_formatted_survives_the_wire(self) -> None:
data = load_data("python", timestamp=before_now(minutes=5))
data["exception"] = {"values": [{"type": "Exception", "value": "boom"}]}
event = self.store_event(data=data, project_id=self.project.id)

path = self._get_path("get_event_details")
response = self.client.post(
path,
data={
"args": {
"event_id": event.event_id,
"project_slug": self.project.slug,
"format": "markdown",
}
},
format="json",
)

assert response.status_code == 200
body = response.json()
assert isinstance(body["formatted"], str)
assert "## Exception" in body["formatted"]
Loading