diff --git a/src/sentry/issues/formatting/limits.py b/src/sentry/issues/formatting/limits.py index 5a524ce451fd..5ce91b3bc632 100644 --- a/src/sentry/issues/formatting/limits.py +++ b/src/sentry/issues/formatting/limits.py @@ -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, +) diff --git a/src/sentry/seer/agent/tools.py b/src/sentry/seer/agent/tools.py index cddb648d8285..ff2070a42628 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 @@ -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 @@ -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. @@ -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.") @@ -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, ) diff --git a/src/sentry/seer/sentry_data_models.py b/src/sentry/seer/sentry_data_models.py index 027c8fb00aea..e7e515aa7898 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; populated when the RPC is called with a `format`, else None + 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..0707059e5ad4 100644 --- a/tests/sentry/seer/agent/test_tools.py +++ b/tests/sentry/seer/agent/test_tools.py @@ -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 diff --git a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py index 6cd4278e9ab4..48fc38eca5ca 100644 --- a/tests/sentry/seer/endpoints/test_organization_seer_rpc.py +++ b/tests/sentry/seer/endpoints/test_organization_seer_rpc.py @@ -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): @@ -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"]