From a44433e5630e654544f5c76a11da7413a0586e2c Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Fri, 17 Jul 2026 11:04:42 -0700 Subject: [PATCH 1/5] rpc --- src/sentry/issues/formatting/limits.py | 10 ++++++++++ src/sentry/seer/agent/tools.py | 22 +++++++++++++++++++++ src/sentry/seer/sentry_data_models.py | 2 ++ tests/sentry/seer/agent/test_tools.py | 27 ++++++++++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/src/sentry/issues/formatting/limits.py b/src/sentry/issues/formatting/limits.py index dac617395c3c..a4639167283a 100644 --- a/src/sentry/issues/formatting/limits.py +++ b/src/sentry/issues/formatting/limits.py @@ -26,3 +26,13 @@ class Limits: max_stacktrace_chars=20_000, max_spans_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, +) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index cddb648d8285..3e9b77d77c18 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -27,6 +27,13 @@ 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.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 @@ -2060,6 +2067,9 @@ def get_event_details( start: str | None = None, end: str | None = None, project_slug: str | None = None, + format: Format | None = None, + format_limits: str = "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. @@ -2161,12 +2171,24 @@ 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 (so it drops its own EventDetails.format_event). + formatted: str | None = None + if format is not None: + 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, ) diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 027c8fb00aea..ae97d0345833 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -477,6 +477,8 @@ class EventDetailsResponse(_DictProxyMixin): event_trace_id: str | None project_id: int project_slug: str + # shared-formatter text, present when the RPC is called with a `format` + formatted: str | None = None class IssueDetailsResponse(_DictProxyMixin): diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index a14ce6a16193..c314830c086e 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -2579,6 +2579,33 @@ 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() + + 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_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_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 From 96cf815870956e00b77373b0e507032ef538054a Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Mon, 20 Jul 2026 09:56:25 -0700 Subject: [PATCH 2/5] seer rpc --- src/sentry/seer/agent/tools.py | 8 +++++--- tests/sentry/seer/agent/test_tools.py | 22 +++++++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index 3e9b77d77c18..539853f5b6b3 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -11,7 +11,7 @@ from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from snuba_sdk import Column, Condition, Entity, Function, Limit, Op, Query, Request -from sentry import eventstore, features +from sentry import eventstore, features, options from sentry.api import client from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS from sentry.api.endpoints.organization_trace_item_attributes_ranked import ( @@ -29,6 +29,7 @@ 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_OPTION from sentry.issues.formatting.sections import ( EVENT_SECTIONS, breadcrumbs_section, @@ -2171,9 +2172,10 @@ 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 (so it drops its own EventDetails.format_event). + # Opt-in shared-formatter output for Seer, gated behind the rollout option so it can be + # ramped gradually; when the option is off, callers fall back to their own formatter. formatted: str | None = None - if format is not None: + if format is not None and options.get(FORMATTER_OPTION): limits = LIMITS_LOW if format_limits == "low" else LIMITS_DEFAULT sections = ( EVENT_SECTIONS diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index c314830c086e..dcbe79a31f2c 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -66,6 +66,7 @@ TestCase, TraceMetricsTestCase, ) +from sentry.testutils.helpers import override_options from sentry.testutils.helpers.datetime import before_now from sentry.types.activity import ActivityType from sentry.utils.dates import parse_stats_period @@ -2582,6 +2583,23 @@ def test_by_event_id_single_project(self) -> None: def test_format_returns_shared_formatter_output(self) -> None: event = self._make_error_event() + with override_options({"issues.standardized-markdown-for-llm": True}): + 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, @@ -2590,9 +2608,7 @@ def test_format_returns_shared_formatter_output(self) -> None: ) assert result is not None - assert result["formatted"] is not None - assert "## Title" in result["formatted"] - assert "## Exception" in result["formatted"] + assert result["formatted"] is None def test_no_format_omits_formatted(self) -> None: event = self._make_error_event() From 2b3a8372dd2253b4af298dfcb0d91757dcd9f56d Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Mon, 20 Jul 2026 11:39:18 -0700 Subject: [PATCH 3/5] clean up --- src/sentry/seer/agent/tools.py | 11 ++++++++--- src/sentry/seer/sentry_data_models.py | 2 +- tests/sentry/seer/agent/test_tools.py | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index 539853f5b6b3..5e2a0652eb06 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -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 @@ -2069,7 +2069,7 @@ def get_event_details( end: str | None = None, project_slug: str | None = None, format: Format | None = None, - format_limits: str = "default", + format_limits: Literal["default", "low"] = "default", include_breadcrumbs: bool = True, ) -> EventDetailsResponse | None: """ @@ -2083,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 ``issues.standardized-markdown-for-llm`` option. + 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.") diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index ae97d0345833..e7e515aa7898 100644 --- a/src/sentry/seer/sentry_data_models.py +++ b/src/sentry/seer/sentry_data_models.py @@ -477,7 +477,7 @@ class EventDetailsResponse(_DictProxyMixin): event_trace_id: str | None project_id: int project_slug: str - # shared-formatter text, present when the RPC is called with a `format` + # shared-formatter text; populated when the RPC is called with a `format`, else None formatted: str | None = None diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index dcbe79a31f2c..fe4c546d289f 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -2622,6 +2622,33 @@ def test_no_format_omits_formatted(self) -> None: 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 override_options({"issues.standardized-markdown-for-llm": True}): + 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 From c57094aef52fd3852dcce1a67b338a66e0210280 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Mon, 20 Jul 2026 12:02:18 -0700 Subject: [PATCH 4/5] test --- .../endpoints/test_organization_seer_rpc.py | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py index 6cd4278e9ab4..7dc98d93cd6c 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py @@ -5,9 +5,12 @@ 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 import override_options +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): @@ -400,3 +403,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") + @override_options({"issues.standardized-markdown-for-llm": True}) + 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"] From fb423be0c7b10bddb678825940513984fa79ac38 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Mon, 20 Jul 2026 12:37:22 -0700 Subject: [PATCH 5/5] feature flag --- src/sentry/seer/agent/tools.py | 12 ++++++------ tests/sentry/seer/agent/test_tools.py | 5 ++--- .../seer/endpoints/test_organization_seer_rpc.py | 3 +-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index 5e2a0652eb06..ff2070a42628 100644 --- a/src/sentry/seer/agent/tools.py +++ b/src/sentry/seer/agent/tools.py @@ -11,7 +11,7 @@ from sentry_protos.snuba.v1.request_common_pb2 import TraceItemType from snuba_sdk import Column, Condition, Entity, Function, Limit, Op, Query, Request -from sentry import eventstore, features, options +from sentry import eventstore, features from sentry.api import client from sentry.api.endpoints.organization_events_timeseries import TOP_EVENTS_DATASETS from sentry.api.endpoints.organization_trace_item_attributes_ranked import ( @@ -29,7 +29,7 @@ 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_OPTION +from sentry.issues.formatting.mixin import FORMATTER_FEATURE from sentry.issues.formatting.sections import ( EVENT_SECTIONS, breadcrumbs_section, @@ -2084,7 +2084,7 @@ def get_event_details( 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 ``issues.standardized-markdown-for-llm`` option. + 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. @@ -2177,10 +2177,10 @@ 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 option so it can be - # ramped gradually; when the option is off, callers fall back to their own formatter. + # 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 options.get(FORMATTER_OPTION): + if format is not None and features.has(FORMATTER_FEATURE, organization): limits = LIMITS_LOW if format_limits == "low" else LIMITS_DEFAULT sections = ( EVENT_SECTIONS diff --git a/tests/sentry/seer/agent/test_tools.py b/tests/sentry/seer/agent/test_tools.py index fe4c546d289f..0707059e5ad4 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -66,7 +66,6 @@ TestCase, TraceMetricsTestCase, ) -from sentry.testutils.helpers import override_options from sentry.testutils.helpers.datetime import before_now from sentry.types.activity import ActivityType from sentry.utils.dates import parse_stats_period @@ -2583,7 +2582,7 @@ def test_by_event_id_single_project(self) -> None: def test_format_returns_shared_formatter_output(self) -> None: event = self._make_error_event() - with override_options({"issues.standardized-markdown-for-llm": True}): + with self.feature("organizations:issue-standardized-markdown-for-llm"): result = get_event_details( organization_id=self.organization.id, event_id=event.event_id, @@ -2629,7 +2628,7 @@ def test_include_breadcrumbs_false_drops_section(self) -> None: } event = self.store_event(data=data, project_id=self.project.id) - with override_options({"issues.standardized-markdown-for-llm": True}): + with self.feature("organizations:issue-standardized-markdown-for-llm"): with_crumbs = get_event_details( organization_id=self.organization.id, event_id=event.event_id, diff --git a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py index 7dc98d93cd6c..48fc38eca5ca 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py @@ -6,7 +6,6 @@ from sentry.models.project import Project from sentry.silo.base import SiloMode from sentry.testutils.cases import APITestCase, SnubaTestCase -from sentry.testutils.helpers import override_options 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 @@ -427,7 +426,7 @@ def _get_path(self, method_name: str) -> str: ) @with_feature("organizations:seer-public-rpc") - @override_options({"issues.standardized-markdown-for-llm": True}) + @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"}]}