From b923f7003c845695cd88bc3aee5691b3551c6d94 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:15:00 -0700 Subject: [PATCH 01/21] option --- src/sentry/issues/formatting/__init__.py | 5 +++++ src/sentry/options/defaults.py | 8 ++++++++ tests/sentry/issues/formatting/test_scaffold.py | 12 ++++++++++++ 3 files changed, 25 insertions(+) create mode 100644 src/sentry/issues/formatting/__init__.py create mode 100644 tests/sentry/issues/formatting/test_scaffold.py diff --git a/src/sentry/issues/formatting/__init__.py b/src/sentry/issues/formatting/__init__.py new file mode 100644 index 000000000000..2c993ca397a4 --- /dev/null +++ b/src/sentry/issues/formatting/__init__.py @@ -0,0 +1,5 @@ +"""Shared issue/event formatter: converts issue/event data into text (markdown/xml) for LLMs. + +One implementation used by every surface (REST API, RPC, direct import) so formatting +stays consistent and a new issue type renders without per-consumer changes. +""" diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 09a304868c3e..c5369cbf5f0e 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -988,6 +988,14 @@ flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) +# Enables the shared issue/event formatter module (markdown/xml output for LLMs). +register( + "issues.standardized-markdown-for-llm", + default=False, + type=Bool, + flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, +) + # Killswitch for all Seer services # # TODO: So far this is only being checked when calling the Seer similar issues service during diff --git a/tests/sentry/issues/formatting/test_scaffold.py b/tests/sentry/issues/formatting/test_scaffold.py new file mode 100644 index 000000000000..7f2bc86d7bc6 --- /dev/null +++ b/tests/sentry/issues/formatting/test_scaffold.py @@ -0,0 +1,12 @@ +from sentry import options + + +def test_package_imports() -> None: + import sentry.issues.formatting # noqa: F401 + + +def test_option_registered() -> None: + # registered with a default of False until a consumer opts in + # lookup_key checks the in-memory registry (no DB); raises UnknownOption if missing + key = options.lookup_key("issues.standardized-markdown-for-llm") + assert key.default() is False From 252d8e04bff06889a22a5d4a5f38c945e2abda36 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:25:51 -0700 Subject: [PATCH 02/21] models --- src/sentry/issues/formatting/models.py | 111 ++++++++++++++++++ tests/sentry/issues/formatting/test_models.py | 81 +++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 src/sentry/issues/formatting/models.py create mode 100644 tests/sentry/issues/formatting/test_models.py diff --git a/src/sentry/issues/formatting/models.py b/src/sentry/issues/formatting/models.py new file mode 100644 index 000000000000..1a3475770e71 --- /dev/null +++ b/src/sentry/issues/formatting/models.py @@ -0,0 +1,111 @@ +"""Formatter model: a flat, event-rooted representation the formatter renders from. + +Adapted from Seer's ``EventDetails`` and friends, but defined here in Sentry (Seer is a +separate service). The model is deliberately flat: issue-level fields live directly on +``EventObject`` rather than in a wrapping object, so a single section list renders any +issue type. An adapter populates these models from a serialized event/issue; the models +themselves are plain snake_case (no camelCase aliasing). + +Trace models are intentionally omitted here; trace formatting is a later milestone. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel + + +class Frame(BaseModel): + function: str | None = None + filename: str | None = None + abs_path: str | None = None + module: str | None = None + package: str | None = None + line_no: int | None = None + col_no: int | None = None + context: list[tuple[int, str | None]] = [] # [(line_no, source_line), ...] + vars: dict[str, Any] | None = None # local variables at the frame + in_app: bool = False + + +class Stacktrace(BaseModel): + frames: list[Frame] = [] + + +class ExceptionDetails(BaseModel): + type: str | None = None # exception class name + value: str | None = None # exception message + stacktrace: Stacktrace | None = None + is_handled: bool | None = None + + +class ThreadDetails(BaseModel): + id: int | str | None = None + name: str | None = None + crashed: bool | None = None + current: bool | None = None + state: str | None = None + stacktrace: Stacktrace | None = None + + +class Breadcrumb(BaseModel): + type: str | None = None + category: str | None = None + level: str | None = None + message: str | None = None + data: dict[str, Any] | None = None + timestamp: str | None = None + + +class RequestDetails(BaseModel): + method: str | None = None + url: str | None = None + data: Any | None = None + # not including cookies, headers, env, query, etc. + + +class EvidenceSpan(BaseModel): + op: str | None = None + description: str | None = None + exclusive_time_ms: float | None = None + + +class UserDetails(BaseModel): + id: str | None = None + email: str | None = None + username: str | None = None + ip_address: str | None = None + + +class EventObject(BaseModel): + """Flat, event-rooted formatter model; issue-level fields are optional.""" + + event_id: str | None = None + title: str + message: str | None = None + platform: str | None = None + transaction_name: str | None = None + timestamp: datetime | None = None + + exceptions: list[ExceptionDetails] = [] + threads: list[ThreadDetails] = [] # same shape as exceptions, for thread dumps + breadcrumbs: list[Breadcrumb] = [] + request: RequestDetails | None = None + tags: list[tuple[str, str | None]] = [] + contexts: dict[str, dict[str, Any]] = {} + user: UserDetails | None = None + spans: list[EvidenceSpan] = [] # perf evidence + occurrence_evidence: str | None = None # perf/occurrence summary + + # issue-level fields (optional; filled when formatting from an issue) + short_id: str | None = None + culprit: str | None = None + status: str | None = None + level: str | None = None + first_seen: datetime | None = None + last_seen: datetime | None = None + count: int | None = None + user_count: int | None = None + permalink: str | None = None diff --git a/tests/sentry/issues/formatting/test_models.py b/tests/sentry/issues/formatting/test_models.py new file mode 100644 index 000000000000..482aedfac973 --- /dev/null +++ b/tests/sentry/issues/formatting/test_models.py @@ -0,0 +1,81 @@ +import pytest +from pydantic import ValidationError + +from sentry.issues.formatting.models import ( + Breadcrumb, + EventObject, + EvidenceSpan, + ExceptionDetails, + Frame, + RequestDetails, + Stacktrace, + ThreadDetails, + UserDetails, +) + + +def test_minimal_event_only_requires_title() -> None: + event = EventObject(title="ValueError: boom") + assert event.title == "ValueError: boom" + # collections default to empty, scalars to None + assert event.exceptions == [] + assert event.threads == [] + assert event.breadcrumbs == [] + assert event.tags == [] + assert event.contexts == {} + assert event.spans == [] + assert event.request is None + assert event.user is None + + +def test_title_is_required() -> None: + with pytest.raises(ValidationError): + EventObject() + + +def test_full_event_nests_submodels() -> None: + event = EventObject( + title="ValueError: boom", + message="boom happened", + exceptions=[ + ExceptionDetails( + type="ValueError", + value="boom", + is_handled=False, + stacktrace=Stacktrace( + frames=[ + Frame( + function="do_thing", + filename="app.py", + line_no=42, + context=[(42, "raise ValueError('boom')")], + in_app=True, + ) + ] + ), + ) + ], + threads=[ThreadDetails(id=0, name="main", crashed=True)], + breadcrumbs=[Breadcrumb(type="log", category="app", level="info", message="started")], + request=RequestDetails(method="GET", url="https://example.com", data={"q": "1"}), + tags=[("environment", "prod"), ("release", None)], + user=UserDetails(id="1", email="user@example.com"), + spans=[EvidenceSpan(op="db", description="SELECT 1", exclusive_time_ms=12.5)], + short_id="PROJ-1", + ) + + assert event.exceptions[0].stacktrace.frames[0].line_no == 42 + assert event.exceptions[0].is_handled is False + assert event.threads[0].crashed is True + assert event.request.method == "GET" + assert event.tags[0] == ("environment", "prod") + assert event.user.email == "user@example.com" + assert event.spans[0].exclusive_time_ms == 12.5 + assert event.short_id == "PROJ-1" + + +def test_defaults_are_not_shared_between_instances() -> None: + a = EventObject(title="a") + b = EventObject(title="b") + a.exceptions.append(ExceptionDetails(type="X")) + assert b.exceptions == [] From 2e37b26d07730fad943faf304ba7cdd59d34a4f7 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:29:02 -0700 Subject: [PATCH 03/21] formatter --- src/sentry/issues/formatting/formatter.py | 78 +++++++++++++++++++ .../issues/formatting/test_formatter.py | 63 +++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/sentry/issues/formatting/formatter.py create mode 100644 tests/sentry/issues/formatting/test_formatter.py diff --git a/src/sentry/issues/formatting/formatter.py b/src/sentry/issues/formatting/formatter.py new file mode 100644 index 000000000000..db5e1346e7cf --- /dev/null +++ b/src/sentry/issues/formatting/formatter.py @@ -0,0 +1,78 @@ +"""The formatter: renders a flat event model into text (markdown or xml). + +Subclasses supply the per-format syntax primitives (``block``/``field``/``code_block``); +the section list decides *what* is rendered and in what order. This mirrors Seer's +per-section approach, made format-polymorphic so one section list drives every format. + +A "section" is a plain function ``(model, formatter, limits) -> str`` that reads the model +and emits text via the formatter's primitives, returning "" to render nothing. Sections and +limits live in their own modules; ``limits`` is typed loosely here until that module lands. +""" + +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence +from typing import Any + +from sentry.issues.formatting.models import EventObject + +logger = logging.getLogger(__name__) + +SectionFn = Callable[["EventObject", "Formatter", Any], str] + + +def slug(title: str) -> str: + """Turn a human title (e.g. "HTTP Request") into an xml-safe tag ("http_request").""" + return re.sub(r"[^a-z0-9]+", "_", title.strip().lower()).strip("_") + + +class Formatter(ABC): + def render(self, model: EventObject, sections: Sequence[SectionFn], limits: Any) -> str: + parts: list[str] = [] + for section in sections: + try: + body = section(model, self, limits) + except Exception: + # one malformed section must not sink the whole output + logger.warning("formatter.section_failed", extra={"section": section.__name__}) + continue + if body: + parts.append(body) + return "\n\n".join(parts) + + # syntax primitives: the only thing that differs per format + @abstractmethod + def block(self, title: str, body: str) -> str: ... + + @abstractmethod + def field(self, key: str, value: str) -> str: ... + + @abstractmethod + def code_block(self, text: str, lang: str | None = None) -> str: ... + + +class MarkdownFormatter(Formatter): + def block(self, title: str, body: str) -> str: + return f"## {title}\n{body}" + + def field(self, key: str, value: str) -> str: + return f"**{key}:** {value}" + + def code_block(self, text: str, lang: str | None = None) -> str: + return f"```{lang or ''}\n{text}\n```" + + +class XmlFormatter(Formatter): + def block(self, title: str, body: str) -> str: + tag = slug(title) + return f"<{tag}>\n{body}\n" + + def field(self, key: str, value: str) -> str: + tag = slug(key) + return f"<{tag}>{value}" + + def code_block(self, text: str, lang: str | None = None) -> str: + return f'{text}' diff --git a/tests/sentry/issues/formatting/test_formatter.py b/tests/sentry/issues/formatting/test_formatter.py new file mode 100644 index 000000000000..6022e11d25f0 --- /dev/null +++ b/tests/sentry/issues/formatting/test_formatter.py @@ -0,0 +1,63 @@ +from sentry.issues.formatting.formatter import ( + Formatter, + MarkdownFormatter, + XmlFormatter, + slug, +) +from sentry.issues.formatting.models import EventObject + + +def title_section(model: EventObject, fmt: Formatter, limits: object) -> str: + return fmt.block("Title", model.title) + + +def empty_section(model: EventObject, fmt: Formatter, limits: object) -> str: + return "" + + +def boom_section(model: EventObject, fmt: Formatter, limits: object) -> str: + raise ValueError("boom") + + +def test_markdown_renders_section() -> None: + event = EventObject(title="ValueError: boom") + out = MarkdownFormatter().render(event, [title_section], limits=None) + assert out == "## Title\nValueError: boom" + + +def test_xml_renders_section_with_slugged_tag() -> None: + event = EventObject(title="ValueError: boom") + out = XmlFormatter().render(event, [title_section], limits=None) + assert out == "\nValueError: boom\n" + + +def test_empty_sections_are_skipped() -> None: + event = EventObject(title="t") + out = MarkdownFormatter().render(event, [empty_section, title_section], limits=None) + assert out == "## Title\nt" # no leading blank from the empty section + + +def test_sections_joined_with_blank_line() -> None: + event = EventObject(title="t") + out = MarkdownFormatter().render(event, [title_section, title_section], limits=None) + assert out == "## Title\nt\n\n## Title\nt" + + +def test_failing_section_does_not_sink_output() -> None: + event = EventObject(title="t") + out = MarkdownFormatter().render(event, [boom_section, title_section], limits=None) + assert out == "## Title\nt" # boom_section swallowed, title still rendered + + +def test_primitives() -> None: + md = MarkdownFormatter() + assert md.field("Handled", "No") == "**Handled:** No" + assert md.code_block("SELECT 1", "sql") == "```sql\nSELECT 1\n```" + + xml = XmlFormatter() + assert xml.field("Handled", "No") == "No" + + +def test_slug() -> None: + assert slug("HTTP Request") == "http_request" + assert slug("Exception") == "exception" From c3b11922f209c975e8d9067ff738ebc6ccb8ecd5 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:36:36 -0700 Subject: [PATCH 04/21] adapter --- src/sentry/issues/formatting/adapter.py | 181 ++++++++++++++++++ .../sentry/issues/formatting/test_adapter.py | 124 ++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 src/sentry/issues/formatting/adapter.py create mode 100644 tests/sentry/issues/formatting/test_adapter.py diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py new file mode 100644 index 000000000000..9c3f2683e15e --- /dev/null +++ b/src/sentry/issues/formatting/adapter.py @@ -0,0 +1,181 @@ +"""Adapter: serialized event dict -> flat ``EventObject``. + +The serialized event (Sentry's ``EventSerializer`` output) is camelCase and nested: data +lives under ``entries[]`` keyed by ``type``, frames use ``lineNo``/``inApp``/``absPath``, +and the handled flag is ``mechanism.handled``. This module does the explicit structural +mapping into our snake_case models. + +It is a pure structural mapping: no truncation or filtering (last-N breadcrumbs, max frames, +skip ``[Filtered]``) happens here. Those are limit concerns handled by the sections + limits +layer, so the model carries the full data and rendering decides what to cap. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any + +from sentry.issues.formatting.models import ( + Breadcrumb, + EventObject, + EvidenceSpan, + ExceptionDetails, + Frame, + RequestDetails, + Stacktrace, + ThreadDetails, + UserDetails, +) + + +def _entries_by_type(data: Mapping[str, Any]) -> dict[str, Any]: + """Map each entry's ``type`` to its ``data``. Later entries of a type win (rare).""" + result: dict[str, Any] = {} + for entry in data.get("entries", []) or []: + if isinstance(entry, Mapping) and "type" in entry: + result[entry["type"]] = entry.get("data") + return result + + +def _frame(f: Mapping[str, Any]) -> Frame: + return Frame( + function=f.get("function"), + filename=f.get("filename"), + abs_path=f.get("absPath"), + module=f.get("module"), + package=f.get("package"), + line_no=f.get("lineNo"), + col_no=f.get("colNo"), + context=f.get("context") or [], + vars=f.get("vars"), + in_app=bool(f.get("inApp")), + ) + + +def _stacktrace(st: Any) -> Stacktrace | None: + if not isinstance(st, Mapping): + return None + frames = [_frame(f) for f in st.get("frames", []) or [] if isinstance(f, Mapping)] + return Stacktrace(frames=frames) + + +def _exception(v: Mapping[str, Any]) -> ExceptionDetails: + mechanism = v.get("mechanism") or {} + handled = mechanism.get("handled") if isinstance(mechanism, Mapping) else None + return ExceptionDetails( + type=v.get("type"), + value=v.get("value"), + stacktrace=_stacktrace(v.get("stacktrace")), + is_handled=handled, + ) + + +def _thread(v: Mapping[str, Any]) -> ThreadDetails: + return ThreadDetails( + id=v.get("id"), + name=v.get("name"), + crashed=v.get("crashed"), + current=v.get("current"), + state=v.get("state"), + stacktrace=_stacktrace(v.get("stacktrace")), + ) + + +def _breadcrumb(v: Mapping[str, Any]) -> Breadcrumb: + return Breadcrumb( + type=v.get("type"), + category=v.get("category"), + level=v.get("level"), + message=v.get("message"), + data=v.get("data"), + timestamp=v.get("timestamp"), + ) + + +def _request(d: Any) -> RequestDetails | None: + if not isinstance(d, Mapping): + return None + return RequestDetails(method=d.get("method"), url=d.get("url"), data=d.get("data")) + + +def _spans(d: Any) -> list[EvidenceSpan]: + if not isinstance(d, Sequence) or isinstance(d, str | bytes): + return [] + return [ + EvidenceSpan( + op=s.get("op"), + description=s.get("description"), + exclusive_time_ms=s.get("exclusiveTime"), + ) + for s in d + if isinstance(s, Mapping) + ] + + +def _user(d: Any) -> UserDetails | None: + if not isinstance(d, Mapping): + return None + return UserDetails( + id=d.get("id"), + email=d.get("email"), + username=d.get("username"), + ip_address=d.get("ipAddress"), + ) + + +def _tags(data: Mapping[str, Any]) -> tuple[list[tuple[str, str | None]], str | None]: + tags: list[tuple[str, str | None]] = [] + transaction_name: str | None = None + for tag in data.get("tags", []) or []: + if not isinstance(tag, Mapping): + continue + key, value = tag.get("key"), tag.get("value") + if key is None: + continue + tags.append((key, value)) + if key == "transaction": + transaction_name = value + return tags, transaction_name + + +def event_response_to_model(data: Mapping[str, Any]) -> EventObject: + """Map a serialized event response into an ``EventObject``.""" + entries = _entries_by_type(data) + + exceptions = [ + _exception(v) + for v in (entries.get("exception") or {}).get("values", []) or [] + if isinstance(v, Mapping) + ] + threads = [ + _thread(v) + for v in (entries.get("threads") or {}).get("values", []) or [] + if isinstance(v, Mapping) + ] + breadcrumbs = [ + _breadcrumb(v) + for v in (entries.get("breadcrumbs") or {}).get("values", []) or [] + if isinstance(v, Mapping) + ] + + message_entry = entries.get("message") or {} + message = message_entry.get("formatted") if isinstance(message_entry, Mapping) else None + + tags, transaction_name = _tags(data) + + return EventObject( + event_id=data.get("eventID") or data.get("id"), + title=data.get("title") or "", + message=message, + platform=data.get("platform"), + transaction_name=transaction_name, + timestamp=data.get("dateCreated") or data.get("dateReceived"), + exceptions=exceptions, + threads=threads, + breadcrumbs=breadcrumbs, + request=_request(entries.get("request")), + tags=tags, + contexts=data.get("contexts") or {}, + user=_user(data.get("user")), + spans=_spans(entries.get("spans")), + ) diff --git a/tests/sentry/issues/formatting/test_adapter.py b/tests/sentry/issues/formatting/test_adapter.py new file mode 100644 index 000000000000..570dbba4bd59 --- /dev/null +++ b/tests/sentry/issues/formatting/test_adapter.py @@ -0,0 +1,124 @@ +from sentry.issues.formatting.adapter import event_response_to_model + + +def _serialized_event() -> dict: + return { + "id": "abc123", + "eventID": "abc123", + "title": "ValueError: boom", + "platform": "python", + "dateCreated": "2024-01-01T00:00:00Z", + "user": {"id": "1", "email": "user@example.com", "ipAddress": "1.2.3.4"}, + "contexts": {"browser": {"name": "Firefox"}}, + "tags": [ + {"key": "environment", "value": "prod"}, + {"key": "transaction", "value": "/checkout"}, + ], + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "type": "ValueError", + "value": "boom", + "mechanism": {"handled": False}, + "stacktrace": { + "frames": [ + { + "function": "do_thing", + "filename": "app.py", + "absPath": "/srv/app.py", + "lineNo": 42, + "colNo": 5, + "inApp": True, + "context": [[42, "raise ValueError('boom')"]], + } + ] + }, + } + ] + }, + }, + { + "type": "breadcrumbs", + "data": {"values": [{"type": "log", "category": "app", "message": "started"}]}, + }, + { + "type": "request", + "data": {"method": "GET", "url": "https://x.com", "data": {"q": 1}}, + }, + {"type": "message", "data": {"formatted": "boom happened"}}, + { + "type": "spans", + "data": [{"op": "db", "description": "SELECT 1", "exclusiveTime": 12.5}], + }, + ], + } + + +def test_maps_event_level_fields() -> None: + m = event_response_to_model(_serialized_event()) + assert m.event_id == "abc123" + assert m.title == "ValueError: boom" + assert m.platform == "python" + assert m.message == "boom happened" + assert m.transaction_name == "/checkout" + assert m.contexts == {"browser": {"name": "Firefox"}} + + +def test_maps_camelcase_frame_and_handled_flag() -> None: + m = event_response_to_model(_serialized_event()) + exc = m.exceptions[0] + assert exc.type == "ValueError" + assert exc.is_handled is False # from mechanism.handled + frame = exc.stacktrace.frames[0] + assert frame.line_no == 42 # lineNo -> line_no + assert frame.col_no == 5 # colNo -> col_no + assert frame.abs_path == "/srv/app.py" # absPath -> abs_path + assert frame.in_app is True # inApp -> in_app + + +def test_maps_breadcrumbs_request_spans_user_tags() -> None: + m = event_response_to_model(_serialized_event()) + assert m.breadcrumbs[0].message == "started" + assert m.request.method == "GET" + assert m.spans[0].exclusive_time_ms == 12.5 # exclusiveTime -> exclusive_time_ms + assert m.user.email == "user@example.com" + assert m.user.ip_address == "1.2.3.4" # ipAddress -> ip_address + assert ("environment", "prod") in m.tags + + +def test_no_truncation_in_adapter() -> None: + # adapter is pure mapping: all breadcrumbs come through, capping is a render concern + data = _serialized_event() + data["entries"].append( + { + "type": "breadcrumbs", + "data": {"values": [{"message": str(i)} for i in range(50)]}, + } + ) + # note: later entry of same type wins, so this replaces the earlier breadcrumbs entry + m = event_response_to_model(data) + assert len(m.breadcrumbs) == 50 + + +def test_minimal_and_malformed_input_do_not_crash() -> None: + # only a title + assert event_response_to_model({"title": "t"}).title == "t" + # missing title falls back to empty string (model requires the field) + assert event_response_to_model({}).title == "" + # malformed entries / values are skipped, not raised + bad = { + "title": "t", + "entries": [ + {"type": "exception", "data": {"values": ["not-a-dict", {"type": "X"}]}}, + "not-an-entry", + ], + "tags": ["not-a-tag", {"key": "ok", "value": "v"}], + "user": "not-a-dict", + } + m = event_response_to_model(bad) + assert [e.type for e in m.exceptions] == ["X"] + assert m.tags == [("ok", "v")] + assert m.user is None From 1b8ef141d07e525caeb99f5f353295108d29088d Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:41:59 -0700 Subject: [PATCH 05/21] limits --- src/sentry/issues/formatting/formatter.py | 10 ++--- src/sentry/issues/formatting/limits.py | 40 +++++++++++++++++++ .../issues/formatting/test_formatter.py | 11 ++--- tests/sentry/issues/formatting/test_limits.py | 36 +++++++++++++++++ 4 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 src/sentry/issues/formatting/limits.py create mode 100644 tests/sentry/issues/formatting/test_limits.py diff --git a/src/sentry/issues/formatting/formatter.py b/src/sentry/issues/formatting/formatter.py index db5e1346e7cf..b7785831a90b 100644 --- a/src/sentry/issues/formatting/formatter.py +++ b/src/sentry/issues/formatting/formatter.py @@ -5,8 +5,8 @@ per-section approach, made format-polymorphic so one section list drives every format. A "section" is a plain function ``(model, formatter, limits) -> str`` that reads the model -and emits text via the formatter's primitives, returning "" to render nothing. Sections and -limits live in their own modules; ``limits`` is typed loosely here until that module lands. +and emits text via the formatter's primitives, returning "" to render nothing. Sections +themselves live in their own module. """ from __future__ import annotations @@ -15,13 +15,13 @@ import re from abc import ABC, abstractmethod from collections.abc import Callable, Sequence -from typing import Any +from sentry.issues.formatting.limits import Limits from sentry.issues.formatting.models import EventObject logger = logging.getLogger(__name__) -SectionFn = Callable[["EventObject", "Formatter", Any], str] +SectionFn = Callable[["EventObject", "Formatter", Limits], str] def slug(title: str) -> str: @@ -30,7 +30,7 @@ def slug(title: str) -> str: class Formatter(ABC): - def render(self, model: EventObject, sections: Sequence[SectionFn], limits: Any) -> str: + def render(self, model: EventObject, sections: Sequence[SectionFn], limits: Limits) -> str: parts: list[str] = [] for section in sections: try: diff --git a/src/sentry/issues/formatting/limits.py b/src/sentry/issues/formatting/limits.py new file mode 100644 index 000000000000..05b411430855 --- /dev/null +++ b/src/sentry/issues/formatting/limits.py @@ -0,0 +1,40 @@ +"""Per-section size limits + presets, mirroring Seer's ``EventFormatLimits``. + +A profile pairs a section list with one of these presets. Sections apply the relevant +caps as they render, so output stays bounded regardless of event size. ``None`` means no +cap for that section. ``max_frames``/``max_breadcrumbs`` are count caps (Seer hardcodes +these; we fold them in here so a profile fully controls its output size). +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Limits: + max_exceptions_chars: int | None = None + max_stacktrace_chars: int | None = None + max_request_chars: int | None = None + max_breadcrumbs_chars: int | None = None + max_single_breadcrumb_chars: int | None = None + max_spans_chars: int | None = None + max_frames: int = 16 + max_breadcrumbs: int = 10 + + +LIMITS_DEFAULT = Limits( + max_exceptions_chars=100_000, + max_stacktrace_chars=20_000, + max_spans_chars=5_000, +) + +# tight context, e.g. the seer summary path +LIMITS_LOW = Limits( + max_exceptions_chars=50_000, + max_stacktrace_chars=10_000, + max_request_chars=2_000, + max_breadcrumbs_chars=5_000, + max_single_breadcrumb_chars=500, + max_spans_chars=5_000, +) diff --git a/tests/sentry/issues/formatting/test_formatter.py b/tests/sentry/issues/formatting/test_formatter.py index 6022e11d25f0..07a8105b2853 100644 --- a/tests/sentry/issues/formatting/test_formatter.py +++ b/tests/sentry/issues/formatting/test_formatter.py @@ -4,6 +4,7 @@ XmlFormatter, slug, ) +from sentry.issues.formatting.limits import LIMITS_DEFAULT from sentry.issues.formatting.models import EventObject @@ -21,31 +22,31 @@ def boom_section(model: EventObject, fmt: Formatter, limits: object) -> str: def test_markdown_renders_section() -> None: event = EventObject(title="ValueError: boom") - out = MarkdownFormatter().render(event, [title_section], limits=None) + out = MarkdownFormatter().render(event, [title_section], limits=LIMITS_DEFAULT) assert out == "## Title\nValueError: boom" def test_xml_renders_section_with_slugged_tag() -> None: event = EventObject(title="ValueError: boom") - out = XmlFormatter().render(event, [title_section], limits=None) + out = XmlFormatter().render(event, [title_section], limits=LIMITS_DEFAULT) assert out == "\nValueError: boom\n" def test_empty_sections_are_skipped() -> None: event = EventObject(title="t") - out = MarkdownFormatter().render(event, [empty_section, title_section], limits=None) + out = MarkdownFormatter().render(event, [empty_section, title_section], limits=LIMITS_DEFAULT) assert out == "## Title\nt" # no leading blank from the empty section def test_sections_joined_with_blank_line() -> None: event = EventObject(title="t") - out = MarkdownFormatter().render(event, [title_section, title_section], limits=None) + out = MarkdownFormatter().render(event, [title_section, title_section], limits=LIMITS_DEFAULT) assert out == "## Title\nt\n\n## Title\nt" def test_failing_section_does_not_sink_output() -> None: event = EventObject(title="t") - out = MarkdownFormatter().render(event, [boom_section, title_section], limits=None) + out = MarkdownFormatter().render(event, [boom_section, title_section], limits=LIMITS_DEFAULT) assert out == "## Title\nt" # boom_section swallowed, title still rendered diff --git a/tests/sentry/issues/formatting/test_limits.py b/tests/sentry/issues/formatting/test_limits.py new file mode 100644 index 000000000000..a8f3d3fed8a4 --- /dev/null +++ b/tests/sentry/issues/formatting/test_limits.py @@ -0,0 +1,36 @@ +import pytest + +from sentry.issues.formatting.limits import LIMITS_DEFAULT, LIMITS_LOW, Limits + + +def test_default_preset_matches_seer() -> None: + assert LIMITS_DEFAULT.max_exceptions_chars == 100_000 + assert LIMITS_DEFAULT.max_stacktrace_chars == 20_000 + assert LIMITS_DEFAULT.max_spans_chars == 5_000 + # uncapped in the default preset + assert LIMITS_DEFAULT.max_request_chars is None + assert LIMITS_DEFAULT.max_breadcrumbs_chars is None + assert LIMITS_DEFAULT.max_single_breadcrumb_chars is None + + +def test_low_preset_matches_seer() -> None: + assert LIMITS_LOW.max_exceptions_chars == 50_000 + assert LIMITS_LOW.max_stacktrace_chars == 10_000 + assert LIMITS_LOW.max_request_chars == 2_000 + assert LIMITS_LOW.max_breadcrumbs_chars == 5_000 + assert LIMITS_LOW.max_single_breadcrumb_chars == 500 + assert LIMITS_LOW.max_spans_chars == 5_000 + + +def test_count_caps_default() -> None: + # folded-in count caps (Seer hardcodes these) + assert LIMITS_DEFAULT.max_frames == 16 + assert LIMITS_DEFAULT.max_breadcrumbs == 10 + assert LIMITS_LOW.max_frames == 16 + assert LIMITS_LOW.max_breadcrumbs == 10 + + +def test_limits_are_frozen() -> None: + limits = Limits() + with pytest.raises((AttributeError, TypeError)): + limits.max_frames = 5 # type: ignore[misc] From a1df427e1141a4249fe07ae82dceff8e61c72d46 Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:46:07 -0700 Subject: [PATCH 06/21] exceptions --- src/sentry/issues/formatting/sections.py | 71 ++++++++++++++++++ .../sentry/issues/formatting/test_sections.py | 75 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/sentry/issues/formatting/sections.py create mode 100644 tests/sentry/issues/formatting/test_sections.py diff --git a/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py new file mode 100644 index 000000000000..98d94dfa5dbf --- /dev/null +++ b/src/sentry/issues/formatting/sections.py @@ -0,0 +1,71 @@ +"""Section functions: each reads the flat model and emits a block via the formatter. + +A section is ``(model, formatter, limits) -> str`` and returns "" to render nothing. Caps +(frame count, per-section chars) are applied here, so the model can carry full data and the +profile's limits decide what actually gets rendered. Content mirrors Seer's per-section output. +""" + +from __future__ import annotations + +from sentry.issues.formatting.formatter import Formatter +from sentry.issues.formatting.limits import Limits +from sentry.issues.formatting.models import EventObject, Frame, Stacktrace + + +def _truncate(text: str, max_chars: int | None) -> str: + if max_chars is None or len(text) <= max_chars: + return text + return text[:max_chars].rstrip() + "\n... (truncated)" + + +def _render_frame(frame: Frame) -> str: + function = frame.function or "Unknown function" + if frame.filename: + location = f"in {frame.filename}" + elif frame.package: + location = f"in package {frame.package}" + else: + location = "in unknown file" + + if frame.line_no is not None: + col = f", column {frame.col_no}" if frame.col_no is not None else "" + line = f" [Line {frame.line_no}{col}]" + else: + line = " [Line: Unknown]" + + app = "In app" if frame.in_app else "Not in app" + header = f"{function} {location}{line} ({app})" + + lines = [header] + for ctx_line_no, source in frame.context: + suspect = " <-- SUSPECT LINE" if ctx_line_no == frame.line_no else "" + lines.append(f"{source or ''}{suspect}") + if frame.vars: + rendered = ", ".join(f"{k}={v!r}" for k, v in frame.vars.items()) + lines.append(f"vars: {rendered}") + return "\n".join(lines) + + +def _render_stacktrace(stacktrace: Stacktrace, limits: Limits) -> str: + # most-recent frame first, capped to max_frames (matches Seer) + frames = list(reversed(stacktrace.frames[-limits.max_frames :])) + body = "\n------\n".join(_render_frame(f) for f in frames) + return _truncate(body, limits.max_stacktrace_chars) + + +def exceptions_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + if not model.exceptions: + return "" + + blocks: list[str] = [] + for exc in model.exceptions: + header = ": ".join(p for p in (exc.type, exc.value) if p) or "Error" + parts = [header] + if exc.is_handled is not None: + parts.append(fmt.field("Handled", "Yes" if exc.is_handled else "No")) + if exc.stacktrace and exc.stacktrace.frames: + parts.append(fmt.code_block(_render_stacktrace(exc.stacktrace, limits))) + blocks.append("\n".join(parts)) + + body = _truncate("\n\n".join(blocks), limits.max_exceptions_chars) + return fmt.block("Exception", body) diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py new file mode 100644 index 000000000000..76c982ca08e4 --- /dev/null +++ b/tests/sentry/issues/formatting/test_sections.py @@ -0,0 +1,75 @@ +import dataclasses + +from sentry.issues.formatting.formatter import MarkdownFormatter, XmlFormatter +from sentry.issues.formatting.limits import LIMITS_DEFAULT +from sentry.issues.formatting.models import EventObject, ExceptionDetails, Frame, Stacktrace +from sentry.issues.formatting.sections import exceptions_section + + +def _event_with_exception(**exc_kwargs) -> EventObject: + return EventObject(title="t", exceptions=[ExceptionDetails(**exc_kwargs)]) + + +def test_no_exceptions_renders_nothing() -> None: + out = exceptions_section(EventObject(title="t"), MarkdownFormatter(), LIMITS_DEFAULT) + assert out == "" + + +def test_basic_exception_with_handled_and_frame() -> None: + event = _event_with_exception( + type="ValueError", + value="boom", + is_handled=False, + stacktrace=Stacktrace( + frames=[ + Frame( + function="do_thing", + filename="app.py", + line_no=42, + in_app=True, + context=[(41, "x = 1"), (42, "raise ValueError('boom')")], + ) + ] + ), + ) + out = exceptions_section(event, MarkdownFormatter(), LIMITS_DEFAULT) + assert out.startswith("## Exception\n") + assert "ValueError: boom" in out + assert "**Handled:** No" in out + assert "do_thing in app.py [Line 42] (In app)" in out + assert "raise ValueError('boom') <-- SUSPECT LINE" in out + assert "x = 1\n" in out # non-suspect context line, no marker + + +def test_handled_omitted_when_none() -> None: + event = _event_with_exception(type="ValueError", value="boom") # is_handled defaults None + out = exceptions_section(event, MarkdownFormatter(), LIMITS_DEFAULT) + assert "Handled" not in out + + +def test_frames_capped_and_most_recent_first() -> None: + frames = [Frame(function=f"f{i}", filename="a.py", line_no=i) for i in range(20)] + event = _event_with_exception(type="E", stacktrace=Stacktrace(frames=frames)) + out = exceptions_section(event, MarkdownFormatter(), LIMITS_DEFAULT) + # default cap is 16 frames; last 16 kept (f4..f19), most-recent first + assert "f19 in a.py" in out + assert "f4 in a.py" in out + assert "f3 in a.py" not in out + assert out.index("f19") < out.index("f4") # reversed order + assert out.count(" in a.py ") == 16 + + +def test_stacktrace_char_truncation() -> None: + frames = [Frame(function="f", filename="a.py", line_no=1)] + event = _event_with_exception(type="E", stacktrace=Stacktrace(frames=frames)) + tight = dataclasses.replace(LIMITS_DEFAULT, max_stacktrace_chars=10) + out = exceptions_section(event, MarkdownFormatter(), tight) + assert "(truncated)" in out + + +def test_xml_output() -> None: + event = _event_with_exception(type="ValueError", value="boom") + out = exceptions_section(event, XmlFormatter(), LIMITS_DEFAULT) + assert out.startswith("\n") + assert out.endswith("\n") + assert "ValueError: boom" in out From d94ea1055bd4ef959dbcae3fadd1492e279d685c Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 12:49:43 -0700 Subject: [PATCH 07/21] other stuff --- src/sentry/issues/formatting/sections.py | 123 +++++++++++++++++ .../sentry/issues/formatting/test_sections.py | 129 +++++++++++++++++- 2 files changed, 250 insertions(+), 2 deletions(-) diff --git a/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py index 98d94dfa5dbf..42a166256e64 100644 --- a/src/sentry/issues/formatting/sections.py +++ b/src/sentry/issues/formatting/sections.py @@ -7,6 +7,8 @@ from __future__ import annotations +from typing import Any + from sentry.issues.formatting.formatter import Formatter from sentry.issues.formatting.limits import Limits from sentry.issues.formatting.models import EventObject, Frame, Stacktrace @@ -18,6 +20,10 @@ def _truncate(text: str, max_chars: int | None) -> str: return text[:max_chars].rstrip() + "\n... (truncated)" +def _contains_filtered(value: Any) -> bool: + return "[Filtered]" in value if isinstance(value, str) else "[Filtered]" in str(value) + + def _render_frame(frame: Frame) -> str: function = frame.function or "Unknown function" if frame.filename: @@ -69,3 +75,120 @@ def exceptions_section(model: EventObject, fmt: Formatter, limits: Limits) -> st body = _truncate("\n\n".join(blocks), limits.max_exceptions_chars) return fmt.block("Exception", body) + + +def title_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + body = model.title + if model.culprit: + body += "\n" + fmt.field("Culprit", model.culprit) + return fmt.block("Title", body) + + +def message_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + # only render the message when it adds something beyond the title (matches Seer) + if not model.message or model.message in model.title: + return "" + return fmt.block("Message", model.message) + + +def breadcrumbs_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + if not model.breadcrumbs: + return "" + + lines: list[str] = [] + for crumb in model.breadcrumbs[-limits.max_breadcrumbs :]: # most recent N + if _contains_filtered(crumb.message) or _contains_filtered(crumb.data): + continue + level = f"[{crumb.level}] " if crumb.level else "" + category = f"{crumb.category}: " if crumb.category else "" + line = f"{level}{category}{crumb.message or ''}".strip() + if crumb.data: + line += f" {crumb.data}" + lines.append(_truncate(line, limits.max_single_breadcrumb_chars)) + + if not lines: + return "" + return fmt.block("Breadcrumbs", _truncate("\n".join(lines), limits.max_breadcrumbs_chars)) + + +def request_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + req = model.request + if not req or not (req.method or req.url or req.data): + return "" + + parts: list[str] = [] + if req.method or req.url: + parts.append(f"{req.method or ''} {req.url or ''}".strip()) + if req.data: + parts.append(fmt.code_block(_truncate(str(req.data), limits.max_request_chars))) + return fmt.block("Request", "\n".join(parts)) + + +def tags_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + if not model.tags: + return "" + body = "\n".join(fmt.field(key, value or "") for key, value in model.tags) + return fmt.block("Tags", body) + + +def user_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + user = model.user + if not user: + return "" + fields = { + "ID": user.id, + "Email": user.email, + "Username": user.username, + "IP": user.ip_address, + } + present = [fmt.field(k, v) for k, v in fields.items() if v] + if not present: + return "" + return fmt.block("User", "\n".join(present)) + + +def threads_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + # only threads that carry a stacktrace are worth rendering (matches Seer) + threads = [t for t in model.threads if t.stacktrace and t.stacktrace.frames] + if not threads: + return "" + + blocks: list[str] = [] + for thread in threads: + label = thread.name or (str(thread.id) if thread.id is not None else "Thread") + parts = [label] + if thread.crashed: + parts.append(fmt.field("Crashed", "Yes")) + assert thread.stacktrace is not None # filtered above + parts.append(fmt.code_block(_render_stacktrace(thread.stacktrace, limits))) + blocks.append("\n".join(parts)) + return fmt.block("Threads", "\n\n".join(blocks)) + + +def spans_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + if not model.spans: + return "" + lines: list[str] = [] + for span in model.spans: + label = ": ".join(p for p in (span.op, span.description) if p) + timing = f" ({span.exclusive_time_ms}ms)" if span.exclusive_time_ms is not None else "" + lines.append(f"{label}{timing}".strip()) + return fmt.block("Span Evidence", _truncate("\n".join(lines), limits.max_spans_chars)) + + +def issue_meta_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: + # issue-level aggregates (mcp profile); omitted when formatting a bare event + fields = { + "Issue ID": model.short_id, + "Status": model.status, + "Level": model.level, + "Events": str(model.count) if model.count is not None else None, + "Users": str(model.user_count) if model.user_count is not None else None, + "First seen": model.first_seen.isoformat() if model.first_seen else None, + "Last seen": model.last_seen.isoformat() if model.last_seen else None, + "Link": model.permalink, + } + present = [fmt.field(k, v) for k, v in fields.items() if v] + if not present: + return "" + return fmt.block("Issue", "\n".join(present)) diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py index 76c982ca08e4..de89e8bde68a 100644 --- a/tests/sentry/issues/formatting/test_sections.py +++ b/tests/sentry/issues/formatting/test_sections.py @@ -1,9 +1,33 @@ import dataclasses +from datetime import datetime from sentry.issues.formatting.formatter import MarkdownFormatter, XmlFormatter from sentry.issues.formatting.limits import LIMITS_DEFAULT -from sentry.issues.formatting.models import EventObject, ExceptionDetails, Frame, Stacktrace -from sentry.issues.formatting.sections import exceptions_section +from sentry.issues.formatting.models import ( + Breadcrumb, + EventObject, + EvidenceSpan, + ExceptionDetails, + Frame, + RequestDetails, + Stacktrace, + ThreadDetails, + UserDetails, +) +from sentry.issues.formatting.sections import ( + breadcrumbs_section, + exceptions_section, + issue_meta_section, + message_section, + request_section, + spans_section, + tags_section, + threads_section, + title_section, + user_section, +) + +MD = MarkdownFormatter() def _event_with_exception(**exc_kwargs) -> EventObject: @@ -73,3 +97,104 @@ def test_xml_output() -> None: assert out.startswith("\n") assert out.endswith("\n") assert "ValueError: boom" in out + + +def test_title_with_culprit() -> None: + event = EventObject(title="ValueError: boom", culprit="app.views.checkout") + out = title_section(event, MD, LIMITS_DEFAULT) + assert "ValueError: boom" in out + assert "**Culprit:** app.views.checkout" in out + + +def test_message_deduped_against_title() -> None: + # message that is a substring of the title renders nothing + assert ( + message_section(EventObject(title="boom happened", message="boom"), MD, LIMITS_DEFAULT) + == "" + ) + # distinct message renders + out = message_section(EventObject(title="t", message="something else"), MD, LIMITS_DEFAULT) + assert "something else" in out + + +def test_breadcrumbs_last_n_and_skip_filtered() -> None: + crumbs = [Breadcrumb(message=f"crumb-{i}") for i in range(15)] + crumbs.append(Breadcrumb(message="[Filtered]")) + event = EventObject(title="t", breadcrumbs=crumbs) + out = breadcrumbs_section(event, MD, LIMITS_DEFAULT) # max_breadcrumbs=10 + assert "[Filtered]" not in out # filtered crumb skipped + assert "crumb-14" in out # most recent kept + assert "crumb-6" in out # boundary of the last-10 window (indices 6..15) + assert "crumb-5" not in out # older than the window, dropped + body_lines = out.split("\n")[1:] # drop the "## Breadcrumbs" heading line + assert len(body_lines) == 9 # last 10 minus the one filtered crumb + + +def test_breadcrumbs_all_filtered_renders_nothing() -> None: + event = EventObject(title="t", breadcrumbs=[Breadcrumb(message="[Filtered]")]) + assert breadcrumbs_section(event, MD, LIMITS_DEFAULT) == "" + + +def test_request_method_url_and_body() -> None: + event = EventObject( + title="t", request=RequestDetails(method="POST", url="https://x.com", data={"a": 1}) + ) + out = request_section(event, MD, LIMITS_DEFAULT) + assert "POST https://x.com" in out + assert "{'a': 1}" in out + + +def test_request_none_renders_nothing() -> None: + assert request_section(EventObject(title="t"), MD, LIMITS_DEFAULT) == "" + + +def test_tags_section() -> None: + event = EventObject(title="t", tags=[("environment", "prod"), ("release", None)]) + out = tags_section(event, MD, LIMITS_DEFAULT) + assert "**environment:** prod" in out + assert "**release:** " in out + + +def test_user_only_present_fields() -> None: + event = EventObject(title="t", user=UserDetails(email="user@example.com")) + out = user_section(event, MD, LIMITS_DEFAULT) + assert "**Email:** user@example.com" in out + assert "ID" not in out + + +def test_threads_only_with_stacktrace() -> None: + with_st = ThreadDetails( + name="main", crashed=True, stacktrace=Stacktrace(frames=[Frame(function="f", line_no=1)]) + ) + without_st = ThreadDetails(name="worker") + event = EventObject(title="t", threads=[with_st, without_st]) + out = threads_section(event, MD, LIMITS_DEFAULT) + assert "main" in out + assert "**Crashed:** Yes" in out + assert "worker" not in out + + +def test_spans_section() -> None: + event = EventObject( + title="t", spans=[EvidenceSpan(op="db", description="SELECT 1", exclusive_time_ms=12.5)] + ) + out = spans_section(event, MD, LIMITS_DEFAULT) + assert "db: SELECT 1 (12.5ms)" in out + + +def test_issue_meta_present_fields() -> None: + event = EventObject( + title="t", + short_id="PROJ-1", + status="unresolved", + count=42, + first_seen=datetime(2024, 1, 1), + ) + out = issue_meta_section(event, MD, LIMITS_DEFAULT) + assert "**Issue ID:** PROJ-1" in out + assert "**Events:** 42" in out + assert "Level" not in out # absent field omitted + + +def test_issue_meta_empty_for_bare_event() -> None: + assert issue_meta_section(EventObject(title="t"), MD, LIMITS_DEFAULT) == "" From e2822687c502e32a3c10e1980fc5ff16f26b615e Mon Sep 17 00:00:00 2001 From: Shayna Chambless Date: Wed, 8 Jul 2026 13:00:33 -0700 Subject: [PATCH 08/21] default profile --- src/sentry/issues/formatting/__init__.py | 4 + src/sentry/issues/formatting/profiles.py | 76 +++++++++++++++++++ src/sentry/issues/formatting/sections.py | 18 ----- .../sentry/issues/formatting/test_profiles.py | 62 +++++++++++++++ .../sentry/issues/formatting/test_sections.py | 20 ----- 5 files changed, 142 insertions(+), 38 deletions(-) create mode 100644 src/sentry/issues/formatting/profiles.py create mode 100644 tests/sentry/issues/formatting/test_profiles.py diff --git a/src/sentry/issues/formatting/__init__.py b/src/sentry/issues/formatting/__init__.py index 2c993ca397a4..af2cb415f3f4 100644 --- a/src/sentry/issues/formatting/__init__.py +++ b/src/sentry/issues/formatting/__init__.py @@ -3,3 +3,7 @@ One implementation used by every surface (REST API, RPC, direct import) so formatting stays consistent and a new issue type renders without per-consumer changes. """ + +from sentry.issues.formatting.profiles import format_issue + +__all__ = ["format_issue"] diff --git a/src/sentry/issues/formatting/profiles.py b/src/sentry/issues/formatting/profiles.py new file mode 100644 index 000000000000..f1c73d41d039 --- /dev/null +++ b/src/sentry/issues/formatting/profiles.py @@ -0,0 +1,76 @@ +"""Profiles + the public ``format_issue`` entry point. + +A profile pairs an ordered section list with a limits preset. For now there is one base +profile that renders the full set of event sections under default limits. Consumer-specific +presets (e.g. tighter limits for Seer, issue aggregates for MCP) are added when those +consumers are migrated; they are internal only and never exposed on the public API. + +``format_issue`` is the single entry point shared by every consumer (REST mixin, RPC, +import): serialized event -> adapter -> model -> formatter, so all paths produce identical +output. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from typing import Any, Literal, NamedTuple + +from sentry.issues.formatting.adapter import event_response_to_model +from sentry.issues.formatting.formatter import Formatter, MarkdownFormatter, SectionFn, XmlFormatter +from sentry.issues.formatting.limits import LIMITS_DEFAULT, Limits +from sentry.issues.formatting.sections import ( + breadcrumbs_section, + exceptions_section, + message_section, + request_section, + spans_section, + tags_section, + threads_section, + title_section, + user_section, +) + +Format = Literal["markdown", "xml"] + +_FORMATTERS: dict[Format, type[Formatter]] = { + "markdown": MarkdownFormatter, + "xml": XmlFormatter, +} + +# base event sections in render order +EVENT_SECTIONS: list[SectionFn] = [ + title_section, + message_section, + exceptions_section, + threads_section, + spans_section, + breadcrumbs_section, + request_section, + tags_section, + user_section, +] + + +class Profile(NamedTuple): + sections: Sequence[SectionFn] + limits: Limits + + +# base profile: everything an event carries, default limits +DEFAULT_PROFILE = Profile(EVENT_SECTIONS, LIMITS_DEFAULT) + + +def format_issue( + data: Mapping[str, Any], + *, + format: Format = "markdown", + profile: Profile = DEFAULT_PROFILE, +) -> str: + """Render a serialized event into text. The single path used by every consumer.""" + try: + formatter_cls = _FORMATTERS[format] + except KeyError: + raise ValueError(f"unsupported format: {format!r}") + + model = event_response_to_model(data) + return formatter_cls().render(model, profile.sections, profile.limits) diff --git a/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py index 42a166256e64..4446b2abfb15 100644 --- a/src/sentry/issues/formatting/sections.py +++ b/src/sentry/issues/formatting/sections.py @@ -174,21 +174,3 @@ def spans_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: timing = f" ({span.exclusive_time_ms}ms)" if span.exclusive_time_ms is not None else "" lines.append(f"{label}{timing}".strip()) return fmt.block("Span Evidence", _truncate("\n".join(lines), limits.max_spans_chars)) - - -def issue_meta_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: - # issue-level aggregates (mcp profile); omitted when formatting a bare event - fields = { - "Issue ID": model.short_id, - "Status": model.status, - "Level": model.level, - "Events": str(model.count) if model.count is not None else None, - "Users": str(model.user_count) if model.user_count is not None else None, - "First seen": model.first_seen.isoformat() if model.first_seen else None, - "Last seen": model.last_seen.isoformat() if model.last_seen else None, - "Link": model.permalink, - } - present = [fmt.field(k, v) for k, v in fields.items() if v] - if not present: - return "" - return fmt.block("Issue", "\n".join(present)) diff --git a/tests/sentry/issues/formatting/test_profiles.py b/tests/sentry/issues/formatting/test_profiles.py new file mode 100644 index 000000000000..b1ad78433ba7 --- /dev/null +++ b/tests/sentry/issues/formatting/test_profiles.py @@ -0,0 +1,62 @@ +import pytest + +from sentry.issues.formatting import format_issue as exported_format_issue +from sentry.issues.formatting.limits import LIMITS_DEFAULT +from sentry.issues.formatting.profiles import DEFAULT_PROFILE, EVENT_SECTIONS, format_issue + + +def _serialized_event() -> dict: + return { + "eventID": "abc123", + "title": "ValueError: boom", + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "type": "ValueError", + "value": "boom", + "mechanism": {"handled": False}, + "stacktrace": { + "frames": [{"function": "f", "filename": "a.py", "lineNo": 1}] + }, + } + ] + }, + }, + {"type": "breadcrumbs", "data": {"values": [{"message": "started"}]}}, + ], + } + + +def test_format_issue_markdown_end_to_end() -> None: + out = format_issue(_serialized_event(), format="markdown") + assert "## Title\nValueError: boom" in out + assert "## Exception" in out + assert "**Handled:** No" in out + assert "## Breadcrumbs" in out + assert "started" in out + + +def test_format_issue_xml() -> None: + out = format_issue(_serialized_event(), format="xml") + assert "" in out + assert "<exception>" in out + assert "<breadcrumbs>" in out + + +def test_invalid_format_raises() -> None: + with pytest.raises(ValueError): + format_issue(_serialized_event(), format="banana") # type: ignore[arg-type] + + +def test_exported_from_package_root() -> None: + # matches the doc's `from sentry.issues.formatting import format_issue` + assert exported_format_issue is format_issue + + +def test_default_profile_wiring() -> None: + # the single base profile: full event section list under default limits + assert DEFAULT_PROFILE.sections is EVENT_SECTIONS + assert DEFAULT_PROFILE.limits is LIMITS_DEFAULT diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py index de89e8bde68a..acdc355d8c92 100644 --- a/tests/sentry/issues/formatting/test_sections.py +++ b/tests/sentry/issues/formatting/test_sections.py @@ -1,5 +1,4 @@ import dataclasses -from datetime import datetime from sentry.issues.formatting.formatter import MarkdownFormatter, XmlFormatter from sentry.issues.formatting.limits import LIMITS_DEFAULT @@ -17,7 +16,6 @@ from sentry.issues.formatting.sections import ( breadcrumbs_section, exceptions_section, - issue_meta_section, message_section, request_section, spans_section, @@ -180,21 +178,3 @@ def test_spans_section() -> None: ) out = spans_section(event, MD, LIMITS_DEFAULT) assert "db: SELECT 1 (12.5ms)" in out - - -def test_issue_meta_present_fields() -> None: - event = EventObject( - title="t", - short_id="PROJ-1", - status="unresolved", - count=42, - first_seen=datetime(2024, 1, 1), - ) - out = issue_meta_section(event, MD, LIMITS_DEFAULT) - assert "**Issue ID:** PROJ-1" in out - assert "**Events:** 42" in out - assert "Level" not in out # absent field omitted - - -def test_issue_meta_empty_for_bare_event() -> None: - assert issue_meta_section(EventObject(title="t"), MD, LIMITS_DEFAULT) == "" From e8f0ed9edc07be6def7888b29980203096c6e03a Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Thu, 9 Jul 2026 13:46:58 -0700 Subject: [PATCH 09/21] de slop --- src/sentry/issues/formatting/__init__.py | 9 --- src/sentry/issues/formatting/adapter.py | 48 ++++-------- src/sentry/issues/formatting/formatter.py | 12 +-- src/sentry/issues/formatting/limits.py | 19 +---- src/sentry/issues/formatting/models.py | 12 +-- src/sentry/issues/formatting/profiles.py | 76 ------------------- src/sentry/issues/formatting/sections.py | 73 +++++++++++++----- tests/sentry/issues/formatting/test_limits.py | 13 +--- .../sentry/issues/formatting/test_profiles.py | 62 --------------- .../sentry/issues/formatting/test_sections.py | 57 ++++++++++++++ 10 files changed, 137 insertions(+), 244 deletions(-) delete mode 100644 src/sentry/issues/formatting/__init__.py delete mode 100644 src/sentry/issues/formatting/profiles.py delete mode 100644 tests/sentry/issues/formatting/test_profiles.py diff --git a/src/sentry/issues/formatting/__init__.py b/src/sentry/issues/formatting/__init__.py deleted file mode 100644 index af2cb415f3f4..000000000000 --- a/src/sentry/issues/formatting/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Shared issue/event formatter: converts issue/event data into text (markdown/xml) for LLMs. - -One implementation used by every surface (REST API, RPC, direct import) so formatting -stays consistent and a new issue type renders without per-consumer changes. -""" - -from sentry.issues.formatting.profiles import format_issue - -__all__ = ["format_issue"] diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py index 9c3f2683e15e..42e3506edc34 100644 --- a/src/sentry/issues/formatting/adapter.py +++ b/src/sentry/issues/formatting/adapter.py @@ -1,13 +1,5 @@ -"""Adapter: serialized event dict -> flat ``EventObject``. - -The serialized event (Sentry's ``EventSerializer`` output) is camelCase and nested: data -lives under ``entries[]`` keyed by ``type``, frames use ``lineNo``/``inApp``/``absPath``, -and the handled flag is ``mechanism.handled``. This module does the explicit structural -mapping into our snake_case models. - -It is a pure structural mapping: no truncation or filtering (last-N breadcrumbs, max frames, -skip ``[Filtered]``) happens here. Those are limit concerns handled by the sections + limits -layer, so the model carries the full data and rendering decides what to cap. +"""Maps a serialized event (camelCase, nested under ``entries[]``) into the snake_case +``EventObject``. Pure structural mapping; truncation and filtering are left to the sections. """ from __future__ import annotations @@ -123,6 +115,13 @@ def _user(d: Any) -> UserDetails | None: ) +def _values(entry_data: Any) -> list[Mapping[str, Any]]: + """The ``values`` list from an entry's data, keeping only well-formed mappings.""" + if not isinstance(entry_data, Mapping): + return [] + return [v for v in entry_data.get("values") or [] if isinstance(v, Mapping)] + + def _tags(data: Mapping[str, Any]) -> tuple[list[tuple[str, str | None]], str | None]: tags: list[tuple[str, str | None]] = [] transaction_name: str | None = None @@ -141,38 +140,19 @@ def _tags(data: Mapping[str, Any]) -> tuple[list[tuple[str, str | None]], str | def event_response_to_model(data: Mapping[str, Any]) -> EventObject: """Map a serialized event response into an ``EventObject``.""" entries = _entries_by_type(data) - - exceptions = [ - _exception(v) - for v in (entries.get("exception") or {}).get("values", []) or [] - if isinstance(v, Mapping) - ] - threads = [ - _thread(v) - for v in (entries.get("threads") or {}).get("values", []) or [] - if isinstance(v, Mapping) - ] - breadcrumbs = [ - _breadcrumb(v) - for v in (entries.get("breadcrumbs") or {}).get("values", []) or [] - if isinstance(v, Mapping) - ] - - message_entry = entries.get("message") or {} - message = message_entry.get("formatted") if isinstance(message_entry, Mapping) else None - + message = entries.get("message") tags, transaction_name = _tags(data) return EventObject( event_id=data.get("eventID") or data.get("id"), title=data.get("title") or "", - message=message, + message=message.get("formatted") if isinstance(message, Mapping) else None, platform=data.get("platform"), transaction_name=transaction_name, timestamp=data.get("dateCreated") or data.get("dateReceived"), - exceptions=exceptions, - threads=threads, - breadcrumbs=breadcrumbs, + exceptions=[_exception(v) for v in _values(entries.get("exception"))], + threads=[_thread(v) for v in _values(entries.get("threads"))], + breadcrumbs=[_breadcrumb(v) for v in _values(entries.get("breadcrumbs"))], request=_request(entries.get("request")), tags=tags, contexts=data.get("contexts") or {}, diff --git a/src/sentry/issues/formatting/formatter.py b/src/sentry/issues/formatting/formatter.py index b7785831a90b..8ad2e4a08077 100644 --- a/src/sentry/issues/formatting/formatter.py +++ b/src/sentry/issues/formatting/formatter.py @@ -1,12 +1,6 @@ -"""The formatter: renders a flat event model into text (markdown or xml). - -Subclasses supply the per-format syntax primitives (``block``/``field``/``code_block``); -the section list decides *what* is rendered and in what order. This mirrors Seer's -per-section approach, made format-polymorphic so one section list drives every format. - -A "section" is a plain function ``(model, formatter, limits) -> str`` that reads the model -and emits text via the formatter's primitives, returning "" to render nothing. Sections -themselves live in their own module. +"""Renders the event model into text. Subclasses supply the per-format syntax primitives +(``block``/``field``/``code_block``); the section list decides what is rendered, so one +section list drives every format. Sections live in ``sections.py``. """ from __future__ import annotations diff --git a/src/sentry/issues/formatting/limits.py b/src/sentry/issues/formatting/limits.py index 05b411430855..98262e01de78 100644 --- a/src/sentry/issues/formatting/limits.py +++ b/src/sentry/issues/formatting/limits.py @@ -1,9 +1,6 @@ -"""Per-section size limits + presets, mirroring Seer's ``EventFormatLimits``. - -A profile pairs a section list with one of these presets. Sections apply the relevant -caps as they render, so output stays bounded regardless of event size. ``None`` means no -cap for that section. ``max_frames``/``max_breadcrumbs`` are count caps (Seer hardcodes -these; we fold them in here so a profile fully controls its output size). +"""Per-section size limits, mirroring Seer's ``EventFormatLimits``. Sections apply these caps +as they render, so output stays bounded. ``None`` means no cap; ``max_frames``/ +``max_breadcrumbs`` are count caps. """ from __future__ import annotations @@ -28,13 +25,3 @@ class Limits: max_stacktrace_chars=20_000, max_spans_chars=5_000, ) - -# tight context, e.g. the seer summary path -LIMITS_LOW = Limits( - max_exceptions_chars=50_000, - max_stacktrace_chars=10_000, - max_request_chars=2_000, - max_breadcrumbs_chars=5_000, - max_single_breadcrumb_chars=500, - max_spans_chars=5_000, -) diff --git a/src/sentry/issues/formatting/models.py b/src/sentry/issues/formatting/models.py index 1a3475770e71..ba29c042386e 100644 --- a/src/sentry/issues/formatting/models.py +++ b/src/sentry/issues/formatting/models.py @@ -1,12 +1,6 @@ -"""Formatter model: a flat, event-rooted representation the formatter renders from. - -Adapted from Seer's ``EventDetails`` and friends, but defined here in Sentry (Seer is a -separate service). The model is deliberately flat: issue-level fields live directly on -``EventObject`` rather than in a wrapping object, so a single section list renders any -issue type. An adapter populates these models from a serialized event/issue; the models -themselves are plain snake_case (no camelCase aliasing). - -Trace models are intentionally omitted here; trace formatting is a later milestone. +"""The flat model the formatter renders from: ``EventObject`` plus its nested pieces. Adapted +from Seer's ``EventDetails`` but defined here in Sentry, and kept flat (issue-level fields live +on ``EventObject``) so one section list renders any issue type. Trace models omitted for now. """ from __future__ import annotations diff --git a/src/sentry/issues/formatting/profiles.py b/src/sentry/issues/formatting/profiles.py deleted file mode 100644 index f1c73d41d039..000000000000 --- a/src/sentry/issues/formatting/profiles.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Profiles + the public ``format_issue`` entry point. - -A profile pairs an ordered section list with a limits preset. For now there is one base -profile that renders the full set of event sections under default limits. Consumer-specific -presets (e.g. tighter limits for Seer, issue aggregates for MCP) are added when those -consumers are migrated; they are internal only and never exposed on the public API. - -``format_issue`` is the single entry point shared by every consumer (REST mixin, RPC, -import): serialized event -> adapter -> model -> formatter, so all paths produce identical -output. -""" - -from __future__ import annotations - -from collections.abc import Mapping, Sequence -from typing import Any, Literal, NamedTuple - -from sentry.issues.formatting.adapter import event_response_to_model -from sentry.issues.formatting.formatter import Formatter, MarkdownFormatter, SectionFn, XmlFormatter -from sentry.issues.formatting.limits import LIMITS_DEFAULT, Limits -from sentry.issues.formatting.sections import ( - breadcrumbs_section, - exceptions_section, - message_section, - request_section, - spans_section, - tags_section, - threads_section, - title_section, - user_section, -) - -Format = Literal["markdown", "xml"] - -_FORMATTERS: dict[Format, type[Formatter]] = { - "markdown": MarkdownFormatter, - "xml": XmlFormatter, -} - -# base event sections in render order -EVENT_SECTIONS: list[SectionFn] = [ - title_section, - message_section, - exceptions_section, - threads_section, - spans_section, - breadcrumbs_section, - request_section, - tags_section, - user_section, -] - - -class Profile(NamedTuple): - sections: Sequence[SectionFn] - limits: Limits - - -# base profile: everything an event carries, default limits -DEFAULT_PROFILE = Profile(EVENT_SECTIONS, LIMITS_DEFAULT) - - -def format_issue( - data: Mapping[str, Any], - *, - format: Format = "markdown", - profile: Profile = DEFAULT_PROFILE, -) -> str: - """Render a serialized event into text. The single path used by every consumer.""" - try: - formatter_cls = _FORMATTERS[format] - except KeyError: - raise ValueError(f"unsupported format: {format!r}") - - model = event_response_to_model(data) - return formatter_cls().render(model, profile.sections, profile.limits) diff --git a/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py index 4446b2abfb15..163bc0f46d50 100644 --- a/src/sentry/issues/formatting/sections.py +++ b/src/sentry/issues/formatting/sections.py @@ -1,16 +1,16 @@ -"""Section functions: each reads the flat model and emits a block via the formatter. - -A section is ``(model, formatter, limits) -> str`` and returns "" to render nothing. Caps -(frame count, per-section chars) are applied here, so the model can carry full data and the -profile's limits decide what actually gets rendered. Content mirrors Seer's per-section output. +"""Section functions (each renders one block), the default section order (``EVENT_SECTIONS``), +and the public ``format_issue`` entry point. Sections apply the size caps as they render; +content mirrors Seer's per-section output. """ from __future__ import annotations -from typing import Any +from collections.abc import Mapping, Sequence +from typing import Any, Literal -from sentry.issues.formatting.formatter import Formatter -from sentry.issues.formatting.limits import Limits +from sentry.issues.formatting.adapter import event_response_to_model +from sentry.issues.formatting.formatter import Formatter, MarkdownFormatter, SectionFn, XmlFormatter +from sentry.issues.formatting.limits import LIMITS_DEFAULT, Limits from sentry.issues.formatting.models import EventObject, Frame, Stacktrace @@ -21,7 +21,7 @@ def _truncate(text: str, max_chars: int | None) -> str: def _contains_filtered(value: Any) -> bool: - return "[Filtered]" in value if isinstance(value, str) else "[Filtered]" in str(value) + return "[Filtered]" in str(value) def _render_frame(frame: Frame) -> str: @@ -148,20 +148,21 @@ def user_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: def threads_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: - # only threads that carry a stacktrace are worth rendering (matches Seer) - threads = [t for t in model.threads if t.stacktrace and t.stacktrace.frames] - if not threads: - return "" - blocks: list[str] = [] - for thread in threads: + for thread in model.threads: + # only threads that carry a stacktrace are worth rendering (matches Seer) + st = thread.stacktrace + if not (st and st.frames): + continue label = thread.name or (str(thread.id) if thread.id is not None else "Thread") parts = [label] if thread.crashed: parts.append(fmt.field("Crashed", "Yes")) - assert thread.stacktrace is not None # filtered above - parts.append(fmt.code_block(_render_stacktrace(thread.stacktrace, limits))) + parts.append(fmt.code_block(_render_stacktrace(st, limits))) blocks.append("\n".join(parts)) + + if not blocks: + return "" return fmt.block("Threads", "\n\n".join(blocks)) @@ -174,3 +175,41 @@ def spans_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: timing = f" ({span.exclusive_time_ms}ms)" if span.exclusive_time_ms is not None else "" lines.append(f"{label}{timing}".strip()) return fmt.block("Span Evidence", _truncate("\n".join(lines), limits.max_spans_chars)) + + +Format = Literal["markdown", "xml"] + +_FORMATTERS: dict[Format, type[Formatter]] = { + "markdown": MarkdownFormatter, + "xml": XmlFormatter, +} + +# base event sections in render order +EVENT_SECTIONS: list[SectionFn] = [ + title_section, + message_section, + exceptions_section, + threads_section, + spans_section, + breadcrumbs_section, + request_section, + tags_section, + user_section, +] + + +def format_issue( + data: Mapping[str, Any], + *, + format: Format = "markdown", + sections: Sequence[SectionFn] = EVENT_SECTIONS, + limits: Limits = LIMITS_DEFAULT, +) -> str: + """Render a serialized event into text. The single path used by every consumer.""" + try: + formatter_cls = _FORMATTERS[format] + except KeyError: + raise ValueError(f"unsupported format: {format!r}") from None + + model = event_response_to_model(data) + return formatter_cls().render(model, sections, limits) diff --git a/tests/sentry/issues/formatting/test_limits.py b/tests/sentry/issues/formatting/test_limits.py index a8f3d3fed8a4..6875fc7daab7 100644 --- a/tests/sentry/issues/formatting/test_limits.py +++ b/tests/sentry/issues/formatting/test_limits.py @@ -1,6 +1,6 @@ import pytest -from sentry.issues.formatting.limits import LIMITS_DEFAULT, LIMITS_LOW, Limits +from sentry.issues.formatting.limits import LIMITS_DEFAULT, Limits def test_default_preset_matches_seer() -> None: @@ -13,21 +13,10 @@ def test_default_preset_matches_seer() -> None: assert LIMITS_DEFAULT.max_single_breadcrumb_chars is None -def test_low_preset_matches_seer() -> None: - assert LIMITS_LOW.max_exceptions_chars == 50_000 - assert LIMITS_LOW.max_stacktrace_chars == 10_000 - assert LIMITS_LOW.max_request_chars == 2_000 - assert LIMITS_LOW.max_breadcrumbs_chars == 5_000 - assert LIMITS_LOW.max_single_breadcrumb_chars == 500 - assert LIMITS_LOW.max_spans_chars == 5_000 - - def test_count_caps_default() -> None: # folded-in count caps (Seer hardcodes these) assert LIMITS_DEFAULT.max_frames == 16 assert LIMITS_DEFAULT.max_breadcrumbs == 10 - assert LIMITS_LOW.max_frames == 16 - assert LIMITS_LOW.max_breadcrumbs == 10 def test_limits_are_frozen() -> None: diff --git a/tests/sentry/issues/formatting/test_profiles.py b/tests/sentry/issues/formatting/test_profiles.py deleted file mode 100644 index b1ad78433ba7..000000000000 --- a/tests/sentry/issues/formatting/test_profiles.py +++ /dev/null @@ -1,62 +0,0 @@ -import pytest - -from sentry.issues.formatting import format_issue as exported_format_issue -from sentry.issues.formatting.limits import LIMITS_DEFAULT -from sentry.issues.formatting.profiles import DEFAULT_PROFILE, EVENT_SECTIONS, format_issue - - -def _serialized_event() -> dict: - return { - "eventID": "abc123", - "title": "ValueError: boom", - "entries": [ - { - "type": "exception", - "data": { - "values": [ - { - "type": "ValueError", - "value": "boom", - "mechanism": {"handled": False}, - "stacktrace": { - "frames": [{"function": "f", "filename": "a.py", "lineNo": 1}] - }, - } - ] - }, - }, - {"type": "breadcrumbs", "data": {"values": [{"message": "started"}]}}, - ], - } - - -def test_format_issue_markdown_end_to_end() -> None: - out = format_issue(_serialized_event(), format="markdown") - assert "## Title\nValueError: boom" in out - assert "## Exception" in out - assert "**Handled:** No" in out - assert "## Breadcrumbs" in out - assert "started" in out - - -def test_format_issue_xml() -> None: - out = format_issue(_serialized_event(), format="xml") - assert "<title>" in out - assert "<exception>" in out - assert "<breadcrumbs>" in out - - -def test_invalid_format_raises() -> None: - with pytest.raises(ValueError): - format_issue(_serialized_event(), format="banana") # type: ignore[arg-type] - - -def test_exported_from_package_root() -> None: - # matches the doc's `from sentry.issues.formatting import format_issue` - assert exported_format_issue is format_issue - - -def test_default_profile_wiring() -> None: - # the single base profile: full event section list under default limits - assert DEFAULT_PROFILE.sections is EVENT_SECTIONS - assert DEFAULT_PROFILE.limits is LIMITS_DEFAULT diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py index acdc355d8c92..d04f186451e0 100644 --- a/tests/sentry/issues/formatting/test_sections.py +++ b/tests/sentry/issues/formatting/test_sections.py @@ -1,5 +1,7 @@ import dataclasses +import pytest + from sentry.issues.formatting.formatter import MarkdownFormatter, XmlFormatter from sentry.issues.formatting.limits import LIMITS_DEFAULT from sentry.issues.formatting.models import ( @@ -14,8 +16,10 @@ UserDetails, ) from sentry.issues.formatting.sections import ( + EVENT_SECTIONS, breadcrumbs_section, exceptions_section, + format_issue, message_section, request_section, spans_section, @@ -178,3 +182,56 @@ def test_spans_section() -> None: ) out = spans_section(event, MD, LIMITS_DEFAULT) assert "db: SELECT 1 (12.5ms)" in out + + +def _serialized_event() -> dict: + return { + "eventID": "abc123", + "title": "ValueError: boom", + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "type": "ValueError", + "value": "boom", + "mechanism": {"handled": False}, + "stacktrace": { + "frames": [{"function": "f", "filename": "a.py", "lineNo": 1}] + }, + } + ] + }, + }, + {"type": "breadcrumbs", "data": {"values": [{"message": "started"}]}}, + ], + } + + +def test_format_issue_markdown_end_to_end() -> None: + out = format_issue(_serialized_event(), format="markdown") + assert "## Title\nValueError: boom" in out + assert "## Exception" in out + assert "**Handled:** No" in out + assert "## Breadcrumbs" in out + assert "started" in out + + +def test_format_issue_xml() -> None: + out = format_issue(_serialized_event(), format="xml") + assert "<title>" in out + assert "<exception>" in out + assert "<breadcrumbs>" in out + + +def test_invalid_format_raises() -> None: + with pytest.raises(ValueError): + format_issue(_serialized_event(), format="banana") # type: ignore[arg-type] + + +def test_event_sections_order() -> None: + names = [s.__name__ for s in EVENT_SECTIONS] + assert names[0] == "title_section" + assert names[-1] == "user_section" + assert "exceptions_section" in names From 1335721da5f524153fbcb9147efe8f6b40b8b2d4 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Thu, 9 Jul 2026 14:01:06 -0700 Subject: [PATCH 10/21] tests --- tests/sentry/issues/formatting/test_adapter.py | 11 ++++++++--- tests/sentry/issues/formatting/test_models.py | 8 ++++++-- tests/sentry/issues/formatting/test_sections.py | 5 +++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/tests/sentry/issues/formatting/test_adapter.py b/tests/sentry/issues/formatting/test_adapter.py index 570dbba4bd59..513fc8e055e4 100644 --- a/tests/sentry/issues/formatting/test_adapter.py +++ b/tests/sentry/issues/formatting/test_adapter.py @@ -1,7 +1,9 @@ +from typing import Any + from sentry.issues.formatting.adapter import event_response_to_model -def _serialized_event() -> dict: +def _serialized_event() -> dict[str, Any]: return { "id": "abc123", "eventID": "abc123", @@ -72,6 +74,7 @@ def test_maps_camelcase_frame_and_handled_flag() -> None: exc = m.exceptions[0] assert exc.type == "ValueError" assert exc.is_handled is False # from mechanism.handled + assert exc.stacktrace is not None frame = exc.stacktrace.frames[0] assert frame.line_no == 42 # lineNo -> line_no assert frame.col_no == 5 # colNo -> col_no @@ -82,11 +85,13 @@ def test_maps_camelcase_frame_and_handled_flag() -> None: def test_maps_breadcrumbs_request_spans_user_tags() -> None: m = event_response_to_model(_serialized_event()) assert m.breadcrumbs[0].message == "started" - assert m.request.method == "GET" assert m.spans[0].exclusive_time_ms == 12.5 # exclusiveTime -> exclusive_time_ms + assert ("environment", "prod") in m.tags + assert m.request is not None + assert m.request.method == "GET" + assert m.user is not None assert m.user.email == "user@example.com" assert m.user.ip_address == "1.2.3.4" # ipAddress -> ip_address - assert ("environment", "prod") in m.tags def test_no_truncation_in_adapter() -> None: diff --git a/tests/sentry/issues/formatting/test_models.py b/tests/sentry/issues/formatting/test_models.py index 482aedfac973..eda0eefe9403 100644 --- a/tests/sentry/issues/formatting/test_models.py +++ b/tests/sentry/issues/formatting/test_models.py @@ -30,7 +30,7 @@ def test_minimal_event_only_requires_title() -> None: def test_title_is_required() -> None: with pytest.raises(ValidationError): - EventObject() + EventObject() # type: ignore[call-arg] # missing required `title` is the point def test_full_event_nests_submodels() -> None: @@ -64,11 +64,15 @@ def test_full_event_nests_submodels() -> None: short_id="PROJ-1", ) - assert event.exceptions[0].stacktrace.frames[0].line_no == 42 + stacktrace = event.exceptions[0].stacktrace + assert stacktrace is not None + assert stacktrace.frames[0].line_no == 42 assert event.exceptions[0].is_handled is False assert event.threads[0].crashed is True + assert event.request is not None assert event.request.method == "GET" assert event.tags[0] == ("environment", "prod") + assert event.user is not None assert event.user.email == "user@example.com" assert event.spans[0].exclusive_time_ms == 12.5 assert event.short_id == "PROJ-1" diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py index d04f186451e0..7303a8a3f987 100644 --- a/tests/sentry/issues/formatting/test_sections.py +++ b/tests/sentry/issues/formatting/test_sections.py @@ -1,4 +1,5 @@ import dataclasses +from typing import Any import pytest @@ -32,7 +33,7 @@ MD = MarkdownFormatter() -def _event_with_exception(**exc_kwargs) -> EventObject: +def _event_with_exception(**exc_kwargs: Any) -> EventObject: return EventObject(title="t", exceptions=[ExceptionDetails(**exc_kwargs)]) @@ -184,7 +185,7 @@ def test_spans_section() -> None: assert "db: SELECT 1 (12.5ms)" in out -def _serialized_event() -> dict: +def _serialized_event() -> dict[str, Any]: return { "eventID": "abc123", "title": "ValueError: boom", From 58ac8b66a9949ee2e41580fac3d75984a2222162 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Thu, 9 Jul 2026 14:29:20 -0700 Subject: [PATCH 11/21] bugbot --- src/sentry/issues/formatting/limits.py | 3 ++- src/sentry/issues/formatting/sections.py | 2 ++ tests/sentry/issues/formatting/test_limits.py | 1 + tests/sentry/issues/formatting/test_sections.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/sentry/issues/formatting/limits.py b/src/sentry/issues/formatting/limits.py index 98262e01de78..dac617395c3c 100644 --- a/src/sentry/issues/formatting/limits.py +++ b/src/sentry/issues/formatting/limits.py @@ -1,6 +1,6 @@ """Per-section size limits, mirroring Seer's ``EventFormatLimits``. Sections apply these caps as they render, so output stays bounded. ``None`` means no cap; ``max_frames``/ -``max_breadcrumbs`` are count caps. +``max_breadcrumbs``/``max_threads`` are count caps. """ from __future__ import annotations @@ -18,6 +18,7 @@ class Limits: max_spans_chars: int | None = None max_frames: int = 16 max_breadcrumbs: int = 10 + max_threads: int = 8 LIMITS_DEFAULT = Limits( diff --git a/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py index 163bc0f46d50..f1686e237cc6 100644 --- a/src/sentry/issues/formatting/sections.py +++ b/src/sentry/issues/formatting/sections.py @@ -160,6 +160,8 @@ def threads_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: parts.append(fmt.field("Crashed", "Yes")) parts.append(fmt.code_block(_render_stacktrace(st, limits))) blocks.append("\n".join(parts)) + if len(blocks) >= limits.max_threads: # bound total output by thread count (matches Seer) + break if not blocks: return "" diff --git a/tests/sentry/issues/formatting/test_limits.py b/tests/sentry/issues/formatting/test_limits.py index 6875fc7daab7..61fce467a36f 100644 --- a/tests/sentry/issues/formatting/test_limits.py +++ b/tests/sentry/issues/formatting/test_limits.py @@ -17,6 +17,7 @@ def test_count_caps_default() -> None: # folded-in count caps (Seer hardcodes these) assert LIMITS_DEFAULT.max_frames == 16 assert LIMITS_DEFAULT.max_breadcrumbs == 10 + assert LIMITS_DEFAULT.max_threads == 8 def test_limits_are_frozen() -> None: diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py index 7303a8a3f987..c3ad6a1af4d0 100644 --- a/tests/sentry/issues/formatting/test_sections.py +++ b/tests/sentry/issues/formatting/test_sections.py @@ -177,6 +177,20 @@ def test_threads_only_with_stacktrace() -> None: assert "worker" not in out +def test_threads_capped_by_max_threads() -> None: + # more threads than max_threads -> section output is bounded by the count cap + frame = Frame(function="f", line_no=1) + threads = [ + ThreadDetails(name=f"t{i}", stacktrace=Stacktrace(frames=[frame])) for i in range(20) + ] + event = EventObject(title="t", threads=threads) + tight = dataclasses.replace(LIMITS_DEFAULT, max_threads=3) + out = threads_section(event, MD, tight) + assert out.count("```") == 3 * 2 # one fenced stacktrace per rendered thread, capped at 3 + assert "t0" in out and "t2" in out + assert "t3" not in out + + def test_spans_section() -> None: event = EventObject( title="t", spans=[EvidenceSpan(op="db", description="SELECT 1", exclusive_time_ms=12.5)] From 7deb23cade7d0e5b5d8f7caac5d0a2bcb4430f18 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Thu, 9 Jul 2026 15:29:44 -0700 Subject: [PATCH 12/21] bugbot --- src/sentry/issues/formatting/adapter.py | 12 +++++++++-- .../sentry/issues/formatting/test_adapter.py | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py index 42e3506edc34..82867e6c5e17 100644 --- a/src/sentry/issues/formatting/adapter.py +++ b/src/sentry/issues/formatting/adapter.py @@ -140,13 +140,21 @@ def _tags(data: Mapping[str, Any]) -> tuple[list[tuple[str, str | None]], str | def event_response_to_model(data: Mapping[str, Any]) -> EventObject: """Map a serialized event response into an ``EventObject``.""" entries = _entries_by_type(data) - message = entries.get("message") tags, transaction_name = _tags(data) + # prefer the message entry's formatted text, then its raw message, then the top-level message + message_entry = entries.get("message") + message = ( + message_entry.get("formatted") or message_entry.get("message") + if isinstance(message_entry, Mapping) + else None + ) or data.get("message") + return EventObject( event_id=data.get("eventID") or data.get("id"), title=data.get("title") or "", - message=message.get("formatted") if isinstance(message, Mapping) else None, + message=message, + culprit=data.get("culprit"), platform=data.get("platform"), transaction_name=transaction_name, timestamp=data.get("dateCreated") or data.get("dateReceived"), diff --git a/tests/sentry/issues/formatting/test_adapter.py b/tests/sentry/issues/formatting/test_adapter.py index 513fc8e055e4..a445edd42503 100644 --- a/tests/sentry/issues/formatting/test_adapter.py +++ b/tests/sentry/issues/formatting/test_adapter.py @@ -69,6 +69,27 @@ def test_maps_event_level_fields() -> None: assert m.contexts == {"browser": {"name": "Firefox"}} +def test_maps_top_level_culprit() -> None: + data = _serialized_event() | {"culprit": "app.views.checkout"} + assert event_response_to_model(data).culprit == "app.views.checkout" + + +def test_message_falls_back_to_entry_message_then_top_level() -> None: + # message entry with `message` but no `formatted` + entry_only = {"title": "t", "entries": [{"type": "message", "data": {"message": "raw msg"}}]} + assert event_response_to_model(entry_only).message == "raw msg" + # no message entry at all -> top-level message fallback + top_level = {"title": "t", "message": "top msg"} + assert event_response_to_model(top_level).message == "top msg" + # formatted wins when present + both = { + "title": "t", + "message": "top msg", + "entries": [{"type": "message", "data": {"formatted": "fmt", "message": "raw"}}], + } + assert event_response_to_model(both).message == "fmt" + + def test_maps_camelcase_frame_and_handled_flag() -> None: m = event_response_to_model(_serialized_event()) exc = m.exceptions[0] From 9ce212ec782959214f0dc5f38c0eb804fa7f2a52 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 09:23:25 -0700 Subject: [PATCH 13/21] bugbot --- src/sentry/issues/formatting/adapter.py | 13 ++++- .../sentry/issues/formatting/test_adapter.py | 49 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py index 82867e6c5e17..ad34f7f869de 100644 --- a/src/sentry/issues/formatting/adapter.py +++ b/src/sentry/issues/formatting/adapter.py @@ -51,13 +51,22 @@ def _stacktrace(st: Any) -> Stacktrace | None: return Stacktrace(frames=frames) +def _best_stacktrace(v: Mapping[str, Any]) -> Stacktrace | None: + # prefer the processed stacktrace; fall back to rawStacktrace when it has the frames + for key in ("stacktrace", "rawStacktrace"): + st = _stacktrace(v.get(key)) + if st and st.frames: + return st + return None + + def _exception(v: Mapping[str, Any]) -> ExceptionDetails: mechanism = v.get("mechanism") or {} handled = mechanism.get("handled") if isinstance(mechanism, Mapping) else None return ExceptionDetails( type=v.get("type"), value=v.get("value"), - stacktrace=_stacktrace(v.get("stacktrace")), + stacktrace=_best_stacktrace(v), is_handled=handled, ) @@ -69,7 +78,7 @@ def _thread(v: Mapping[str, Any]) -> ThreadDetails: crashed=v.get("crashed"), current=v.get("current"), state=v.get("state"), - stacktrace=_stacktrace(v.get("stacktrace")), + stacktrace=_best_stacktrace(v), ) diff --git a/tests/sentry/issues/formatting/test_adapter.py b/tests/sentry/issues/formatting/test_adapter.py index a445edd42503..ec7c8e7574eb 100644 --- a/tests/sentry/issues/formatting/test_adapter.py +++ b/tests/sentry/issues/formatting/test_adapter.py @@ -103,6 +103,55 @@ def test_maps_camelcase_frame_and_handled_flag() -> None: assert frame.in_app is True # inApp -> in_app +def test_falls_back_to_raw_stacktrace() -> None: + # processed stacktrace has no frames, but rawStacktrace does -> use raw + data = { + "title": "t", + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "type": "E", + "stacktrace": {"frames": []}, + "rawStacktrace": {"frames": [{"function": "raw_fn", "lineNo": 9}]}, + } + ] + }, + } + ], + } + m = event_response_to_model(data) + st = m.exceptions[0].stacktrace + assert st is not None + assert st.frames[0].function == "raw_fn" + + +def test_processed_stacktrace_preferred_over_raw() -> None: + data = { + "title": "t", + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "type": "E", + "stacktrace": {"frames": [{"function": "processed_fn"}]}, + "rawStacktrace": {"frames": [{"function": "raw_fn"}]}, + } + ] + }, + } + ], + } + m = event_response_to_model(data) + st = m.exceptions[0].stacktrace + assert st is not None + assert st.frames[0].function == "processed_fn" + + def test_maps_breadcrumbs_request_spans_user_tags() -> None: m = event_response_to_model(_serialized_event()) assert m.breadcrumbs[0].message == "started" From 7328e6526fcea2110d223f54c448cc4e3447ae3a Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 09:45:57 -0700 Subject: [PATCH 14/21] span evidence --- src/sentry/issues/formatting/sections.py | 12 ++++++++---- tests/sentry/issues/formatting/test_sections.py | 11 +++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py index f1686e237cc6..fd22bb1144f6 100644 --- a/src/sentry/issues/formatting/sections.py +++ b/src/sentry/issues/formatting/sections.py @@ -104,7 +104,8 @@ def breadcrumbs_section(model: EventObject, fmt: Formatter, limits: Limits) -> s line = f"{level}{category}{crumb.message or ''}".strip() if crumb.data: line += f" {crumb.data}" - lines.append(_truncate(line, limits.max_single_breadcrumb_chars)) + if line: + lines.append(_truncate(line, limits.max_single_breadcrumb_chars)) if not lines: return "" @@ -169,13 +170,16 @@ def threads_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: def spans_section(model: EventObject, fmt: Formatter, limits: Limits) -> str: - if not model.spans: - return "" lines: list[str] = [] for span in model.spans: label = ": ".join(p for p in (span.op, span.description) if p) timing = f" ({span.exclusive_time_ms}ms)" if span.exclusive_time_ms is not None else "" - lines.append(f"{label}{timing}".strip()) + line = f"{label}{timing}".strip() + if line: + lines.append(line) + + if not lines: + return "" return fmt.block("Span Evidence", _truncate("\n".join(lines), limits.max_spans_chars)) diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py index c3ad6a1af4d0..1edcd7919894 100644 --- a/tests/sentry/issues/formatting/test_sections.py +++ b/tests/sentry/issues/formatting/test_sections.py @@ -199,6 +199,17 @@ def test_spans_section() -> None: assert "db: SELECT 1 (12.5ms)" in out +def test_spans_all_blank_renders_nothing() -> None: + # spans present but every field empty -> no empty "## Span Evidence" block + event = EventObject(title="t", spans=[EvidenceSpan(), EvidenceSpan()]) + assert spans_section(event, MD, LIMITS_DEFAULT) == "" + + +def test_breadcrumbs_all_blank_renders_nothing() -> None: + event = EventObject(title="t", breadcrumbs=[Breadcrumb(), Breadcrumb()]) + assert breadcrumbs_section(event, MD, LIMITS_DEFAULT) == "" + + def _serialized_event() -> dict[str, Any]: return { "eventID": "abc123", From 23ace661df1ce2f36eafbb64e7fc781f3d1a1106 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 10:13:59 -0700 Subject: [PATCH 15/21] de slop --- src/sentry/issues/formatting/models.py | 11 +++++------ tests/sentry/issues/formatting/test_scaffold.py | 5 ----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/sentry/issues/formatting/models.py b/src/sentry/issues/formatting/models.py index ba29c042386e..f2a5d7ccf199 100644 --- a/src/sentry/issues/formatting/models.py +++ b/src/sentry/issues/formatting/models.py @@ -20,7 +20,7 @@ class Frame(BaseModel): line_no: int | None = None col_no: int | None = None context: list[tuple[int, str | None]] = [] # [(line_no, source_line), ...] - vars: dict[str, Any] | None = None # local variables at the frame + vars: dict[str, Any] | None = None in_app: bool = False @@ -29,8 +29,8 @@ class Stacktrace(BaseModel): class ExceptionDetails(BaseModel): - type: str | None = None # exception class name - value: str | None = None # exception message + type: str | None = None + value: str | None = None stacktrace: Stacktrace | None = None is_handled: bool | None = None @@ -84,14 +84,13 @@ class EventObject(BaseModel): timestamp: datetime | None = None exceptions: list[ExceptionDetails] = [] - threads: list[ThreadDetails] = [] # same shape as exceptions, for thread dumps + threads: list[ThreadDetails] = [] breadcrumbs: list[Breadcrumb] = [] request: RequestDetails | None = None tags: list[tuple[str, str | None]] = [] contexts: dict[str, dict[str, Any]] = {} user: UserDetails | None = None - spans: list[EvidenceSpan] = [] # perf evidence - occurrence_evidence: str | None = None # perf/occurrence summary + spans: list[EvidenceSpan] = [] # issue-level fields (optional; filled when formatting from an issue) short_id: str | None = None diff --git a/tests/sentry/issues/formatting/test_scaffold.py b/tests/sentry/issues/formatting/test_scaffold.py index 7f2bc86d7bc6..467a770bbddf 100644 --- a/tests/sentry/issues/formatting/test_scaffold.py +++ b/tests/sentry/issues/formatting/test_scaffold.py @@ -1,12 +1,7 @@ from sentry import options -def test_package_imports() -> None: - import sentry.issues.formatting # noqa: F401 - - def test_option_registered() -> None: - # registered with a default of False until a consumer opts in # lookup_key checks the in-memory registry (no DB); raises UnknownOption if missing key = options.lookup_key("issues.standardized-markdown-for-llm") assert key.default() is False From 3b6e435b534cc62d17f30703aa48c8224592a6a0 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 10:28:39 -0700 Subject: [PATCH 16/21] de slop --- src/sentry/issues/formatting/formatter.py | 10 +++++----- tests/sentry/issues/formatting/test_formatter.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/sentry/issues/formatting/formatter.py b/src/sentry/issues/formatting/formatter.py index 8ad2e4a08077..74c3d895f9bc 100644 --- a/src/sentry/issues/formatting/formatter.py +++ b/src/sentry/issues/formatting/formatter.py @@ -45,7 +45,7 @@ def block(self, title: str, body: str) -> str: ... def field(self, key: str, value: str) -> str: ... @abstractmethod - def code_block(self, text: str, lang: str | None = None) -> str: ... + def code_block(self, text: str) -> str: ... class MarkdownFormatter(Formatter): @@ -55,8 +55,8 @@ def block(self, title: str, body: str) -> str: def field(self, key: str, value: str) -> str: return f"**{key}:** {value}" - def code_block(self, text: str, lang: str | None = None) -> str: - return f"```{lang or ''}\n{text}\n```" + def code_block(self, text: str) -> str: + return f"```\n{text}\n```" class XmlFormatter(Formatter): @@ -68,5 +68,5 @@ def field(self, key: str, value: str) -> str: tag = slug(key) return f"<{tag}>{value}</{tag}>" - def code_block(self, text: str, lang: str | None = None) -> str: - return f'<code lang="{lang or ""}">{text}</code>' + def code_block(self, text: str) -> str: + return f"<code>{text}</code>" diff --git a/tests/sentry/issues/formatting/test_formatter.py b/tests/sentry/issues/formatting/test_formatter.py index 07a8105b2853..7b435ec4ce7f 100644 --- a/tests/sentry/issues/formatting/test_formatter.py +++ b/tests/sentry/issues/formatting/test_formatter.py @@ -53,10 +53,11 @@ def test_failing_section_does_not_sink_output() -> None: def test_primitives() -> None: md = MarkdownFormatter() assert md.field("Handled", "No") == "**Handled:** No" - assert md.code_block("SELECT 1", "sql") == "```sql\nSELECT 1\n```" + assert md.code_block("SELECT 1") == "```\nSELECT 1\n```" xml = XmlFormatter() assert xml.field("Handled", "No") == "<handled>No</handled>" + assert xml.code_block("SELECT 1") == "<code>SELECT 1</code>" def test_slug() -> None: From b806db478a51895975456e789f01b78a43168d6a Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 12:53:48 -0700 Subject: [PATCH 17/21] remove test --- tests/sentry/issues/formatting/test_scaffold.py | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 tests/sentry/issues/formatting/test_scaffold.py diff --git a/tests/sentry/issues/formatting/test_scaffold.py b/tests/sentry/issues/formatting/test_scaffold.py deleted file mode 100644 index 467a770bbddf..000000000000 --- a/tests/sentry/issues/formatting/test_scaffold.py +++ /dev/null @@ -1,7 +0,0 @@ -from sentry import options - - -def test_option_registered() -> None: - # lookup_key checks the in-memory registry (no DB); raises UnknownOption if missing - key = options.lookup_key("issues.standardized-markdown-for-llm") - assert key.default() is False From 32f314ba9fe57c5e33903c502972629f9d646c95 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 14:04:37 -0700 Subject: [PATCH 18/21] adapter --- src/sentry/issues/formatting/adapter.py | 96 ++++++------------------- src/sentry/issues/formatting/models.py | 25 +++++-- 2 files changed, 40 insertions(+), 81 deletions(-) diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py index ad34f7f869de..9481a19411a0 100644 --- a/src/sentry/issues/formatting/adapter.py +++ b/src/sentry/issues/formatting/adapter.py @@ -12,13 +12,16 @@ EventObject, EvidenceSpan, ExceptionDetails, - Frame, RequestDetails, Stacktrace, ThreadDetails, UserDetails, ) +# Models whose fields all match the serialized keys (single-word, or aliased for camelCase) +# are parsed directly with ``.parse_obj``. The helpers below only exist where the serialized +# shape needs real work: nested extraction, fallbacks, or structural reshaping. + def _entries_by_type(data: Mapping[str, Any]) -> dict[str, Any]: """Map each entry's ``type`` to its ``data``. Later entries of a type win (rare).""" @@ -29,38 +32,19 @@ def _entries_by_type(data: Mapping[str, Any]) -> dict[str, Any]: return result -def _frame(f: Mapping[str, Any]) -> Frame: - return Frame( - function=f.get("function"), - filename=f.get("filename"), - abs_path=f.get("absPath"), - module=f.get("module"), - package=f.get("package"), - line_no=f.get("lineNo"), - col_no=f.get("colNo"), - context=f.get("context") or [], - vars=f.get("vars"), - in_app=bool(f.get("inApp")), - ) - - -def _stacktrace(st: Any) -> Stacktrace | None: - if not isinstance(st, Mapping): - return None - frames = [_frame(f) for f in st.get("frames", []) or [] if isinstance(f, Mapping)] - return Stacktrace(frames=frames) - - def _best_stacktrace(v: Mapping[str, Any]) -> Stacktrace | None: # prefer the processed stacktrace; fall back to rawStacktrace when it has the frames for key in ("stacktrace", "rawStacktrace"): - st = _stacktrace(v.get(key)) - if st and st.frames: - return st + st = v.get(key) + if isinstance(st, Mapping): + parsed = Stacktrace.parse_obj(st) + if parsed.frames: + return parsed return None def _exception(v: Mapping[str, Any]) -> ExceptionDetails: + # is_handled is nested under mechanism, and the stacktrace needs the raw/processed fallback mechanism = v.get("mechanism") or {} handled = mechanism.get("handled") if isinstance(mechanism, Mapping) else None return ExceptionDetails( @@ -72,56 +56,17 @@ def _exception(v: Mapping[str, Any]) -> ExceptionDetails: def _thread(v: Mapping[str, Any]) -> ThreadDetails: - return ThreadDetails( - id=v.get("id"), - name=v.get("name"), - crashed=v.get("crashed"), - current=v.get("current"), - state=v.get("state"), - stacktrace=_best_stacktrace(v), - ) - - -def _breadcrumb(v: Mapping[str, Any]) -> Breadcrumb: - return Breadcrumb( - type=v.get("type"), - category=v.get("category"), - level=v.get("level"), - message=v.get("message"), - data=v.get("data"), - timestamp=v.get("timestamp"), - ) - - -def _request(d: Any) -> RequestDetails | None: - if not isinstance(d, Mapping): - return None - return RequestDetails(method=d.get("method"), url=d.get("url"), data=d.get("data")) + # threads carry a stacktrace that needs the same raw/processed fallback + thread = ThreadDetails.parse_obj(v) + thread.stacktrace = _best_stacktrace(v) + return thread def _spans(d: Any) -> list[EvidenceSpan]: + # the spans entry's data is a bare list, not the usual {"values": [...]} if not isinstance(d, Sequence) or isinstance(d, str | bytes): return [] - return [ - EvidenceSpan( - op=s.get("op"), - description=s.get("description"), - exclusive_time_ms=s.get("exclusiveTime"), - ) - for s in d - if isinstance(s, Mapping) - ] - - -def _user(d: Any) -> UserDetails | None: - if not isinstance(d, Mapping): - return None - return UserDetails( - id=d.get("id"), - email=d.get("email"), - username=d.get("username"), - ip_address=d.get("ipAddress"), - ) + return [EvidenceSpan.parse_obj(s) for s in d if isinstance(s, Mapping)] def _values(entry_data: Any) -> list[Mapping[str, Any]]: @@ -159,6 +104,9 @@ def event_response_to_model(data: Mapping[str, Any]) -> EventObject: else None ) or data.get("message") + request = entries.get("request") + user = data.get("user") + return EventObject( event_id=data.get("eventID") or data.get("id"), title=data.get("title") or "", @@ -169,10 +117,10 @@ def event_response_to_model(data: Mapping[str, Any]) -> EventObject: timestamp=data.get("dateCreated") or data.get("dateReceived"), exceptions=[_exception(v) for v in _values(entries.get("exception"))], threads=[_thread(v) for v in _values(entries.get("threads"))], - breadcrumbs=[_breadcrumb(v) for v in _values(entries.get("breadcrumbs"))], - request=_request(entries.get("request")), + breadcrumbs=[Breadcrumb.parse_obj(v) for v in _values(entries.get("breadcrumbs"))], + request=RequestDetails.parse_obj(request) if isinstance(request, Mapping) else None, tags=tags, contexts=data.get("contexts") or {}, - user=_user(data.get("user")), + user=UserDetails.parse_obj(user) if isinstance(user, Mapping) else None, spans=_spans(entries.get("spans")), ) diff --git a/src/sentry/issues/formatting/models.py b/src/sentry/issues/formatting/models.py index f2a5d7ccf199..8a38c0715a51 100644 --- a/src/sentry/issues/formatting/models.py +++ b/src/sentry/issues/formatting/models.py @@ -8,20 +8,25 @@ from datetime import datetime from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field class Frame(BaseModel): + # accept the serialized camelCase keys (absPath, lineNo, ...) via aliases, + # while still allowing snake_case construction in-code + class Config: + allow_population_by_field_name = True + function: str | None = None filename: str | None = None - abs_path: str | None = None + abs_path: str | None = Field(None, alias="absPath") module: str | None = None package: str | None = None - line_no: int | None = None - col_no: int | None = None + line_no: int | None = Field(None, alias="lineNo") + col_no: int | None = Field(None, alias="colNo") context: list[tuple[int, str | None]] = [] # [(line_no, source_line), ...] vars: dict[str, Any] | None = None - in_app: bool = False + in_app: bool = Field(False, alias="inApp") class Stacktrace(BaseModel): @@ -61,16 +66,22 @@ class RequestDetails(BaseModel): class EvidenceSpan(BaseModel): + class Config: + allow_population_by_field_name = True + op: str | None = None description: str | None = None - exclusive_time_ms: float | None = None + exclusive_time_ms: float | None = Field(None, alias="exclusiveTime") class UserDetails(BaseModel): + class Config: + allow_population_by_field_name = True + id: str | None = None email: str | None = None username: str | None = None - ip_address: str | None = None + ip_address: str | None = Field(None, alias="ipAddress") class EventObject(BaseModel): From b4b49a0f842e8edf5e7a85842bd3ad8899c8cce2 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Mon, 13 Jul 2026 15:04:26 -0700 Subject: [PATCH 19/21] adapter --- src/sentry/issues/formatting/adapter.py | 84 ++++++------------- .../sentry/issues/formatting/test_adapter.py | 26 ++---- 2 files changed, 33 insertions(+), 77 deletions(-) diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py index 9481a19411a0..8807d841700f 100644 --- a/src/sentry/issues/formatting/adapter.py +++ b/src/sentry/issues/formatting/adapter.py @@ -1,10 +1,13 @@ -"""Maps a serialized event (camelCase, nested under ``entries[]``) into the snake_case -``EventObject``. Pure structural mapping; truncation and filtering are left to the sections. +"""Maps a serialized event (camelCase, nested under ``entries[]``) into the flat ``EventObject``. + +Fields whose serialized keys match the model (directly or via alias) are parsed with +``.parse_obj``. The helpers below cover the cases that need real work: nested extraction, +the raw/processed stacktrace fallback, and reshaping tags. """ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Mapping from typing import Any from sentry.issues.formatting.models import ( @@ -18,25 +21,20 @@ UserDetails, ) -# Models whose fields all match the serialized keys (single-word, or aliased for camelCase) -# are parsed directly with ``.parse_obj``. The helpers below only exist where the serialized -# shape needs real work: nested extraction, fallbacks, or structural reshaping. - def _entries_by_type(data: Mapping[str, Any]) -> dict[str, Any]: - """Map each entry's ``type`` to its ``data``. Later entries of a type win (rare).""" - result: dict[str, Any] = {} - for entry in data.get("entries", []) or []: - if isinstance(entry, Mapping) and "type" in entry: - result[entry["type"]] = entry.get("data") - return result + return {entry["type"]: entry.get("data") for entry in data.get("entries") or []} + + +def _values(entry_data: Any) -> list[Any]: + return (entry_data or {}).get("values") or [] def _best_stacktrace(v: Mapping[str, Any]) -> Stacktrace | None: - # prefer the processed stacktrace; fall back to rawStacktrace when it has the frames + # prefer the processed stacktrace; fall back to rawStacktrace when that's where the frames are for key in ("stacktrace", "rawStacktrace"): st = v.get(key) - if isinstance(st, Mapping): + if st: parsed = Stacktrace.parse_obj(st) if parsed.frames: return parsed @@ -44,72 +42,40 @@ def _best_stacktrace(v: Mapping[str, Any]) -> Stacktrace | None: def _exception(v: Mapping[str, Any]) -> ExceptionDetails: - # is_handled is nested under mechanism, and the stacktrace needs the raw/processed fallback - mechanism = v.get("mechanism") or {} - handled = mechanism.get("handled") if isinstance(mechanism, Mapping) else None return ExceptionDetails( type=v.get("type"), value=v.get("value"), stacktrace=_best_stacktrace(v), - is_handled=handled, + is_handled=(v.get("mechanism") or {}).get("handled"), # nested under mechanism ) def _thread(v: Mapping[str, Any]) -> ThreadDetails: - # threads carry a stacktrace that needs the same raw/processed fallback thread = ThreadDetails.parse_obj(v) - thread.stacktrace = _best_stacktrace(v) + thread.stacktrace = _best_stacktrace(v) # same raw/processed fallback as exceptions return thread -def _spans(d: Any) -> list[EvidenceSpan]: - # the spans entry's data is a bare list, not the usual {"values": [...]} - if not isinstance(d, Sequence) or isinstance(d, str | bytes): - return [] - return [EvidenceSpan.parse_obj(s) for s in d if isinstance(s, Mapping)] - - -def _values(entry_data: Any) -> list[Mapping[str, Any]]: - """The ``values`` list from an entry's data, keeping only well-formed mappings.""" - if not isinstance(entry_data, Mapping): - return [] - return [v for v in entry_data.get("values") or [] if isinstance(v, Mapping)] - - def _tags(data: Mapping[str, Any]) -> tuple[list[tuple[str, str | None]], str | None]: - tags: list[tuple[str, str | None]] = [] - transaction_name: str | None = None - for tag in data.get("tags", []) or []: - if not isinstance(tag, Mapping): - continue - key, value = tag.get("key"), tag.get("value") - if key is None: - continue - tags.append((key, value)) - if key == "transaction": - transaction_name = value + tags = [(tag["key"], tag.get("value")) for tag in data.get("tags") or []] + transaction_name = next((value for key, value in tags if key == "transaction"), None) return tags, transaction_name def event_response_to_model(data: Mapping[str, Any]) -> EventObject: - """Map a serialized event response into an ``EventObject``.""" entries = _entries_by_type(data) tags, transaction_name = _tags(data) - # prefer the message entry's formatted text, then its raw message, then the top-level message - message_entry = entries.get("message") - message = ( - message_entry.get("formatted") or message_entry.get("message") - if isinstance(message_entry, Mapping) - else None - ) or data.get("message") + # message entry's formatted text, then its raw message, then the top-level message + message_entry = entries.get("message") or {} + message = message_entry.get("formatted") or message_entry.get("message") or data.get("message") request = entries.get("request") user = data.get("user") return EventObject( - event_id=data.get("eventID") or data.get("id"), - title=data.get("title") or "", + event_id=data.get("eventID"), + title=data["title"], message=message, culprit=data.get("culprit"), platform=data.get("platform"), @@ -118,9 +84,9 @@ def event_response_to_model(data: Mapping[str, Any]) -> EventObject: exceptions=[_exception(v) for v in _values(entries.get("exception"))], threads=[_thread(v) for v in _values(entries.get("threads"))], breadcrumbs=[Breadcrumb.parse_obj(v) for v in _values(entries.get("breadcrumbs"))], - request=RequestDetails.parse_obj(request) if isinstance(request, Mapping) else None, + request=RequestDetails.parse_obj(request) if request else None, tags=tags, contexts=data.get("contexts") or {}, - user=UserDetails.parse_obj(user) if isinstance(user, Mapping) else None, - spans=_spans(entries.get("spans")), + user=UserDetails.parse_obj(user) if user else None, + spans=[EvidenceSpan.parse_obj(s) for s in entries.get("spans") or []], ) diff --git a/tests/sentry/issues/formatting/test_adapter.py b/tests/sentry/issues/formatting/test_adapter.py index ec7c8e7574eb..39abb41e413c 100644 --- a/tests/sentry/issues/formatting/test_adapter.py +++ b/tests/sentry/issues/formatting/test_adapter.py @@ -178,22 +178,12 @@ def test_no_truncation_in_adapter() -> None: assert len(m.breadcrumbs) == 50 -def test_minimal_and_malformed_input_do_not_crash() -> None: - # only a title - assert event_response_to_model({"title": "t"}).title == "t" - # missing title falls back to empty string (model requires the field) - assert event_response_to_model({}).title == "" - # malformed entries / values are skipped, not raised - bad = { - "title": "t", - "entries": [ - {"type": "exception", "data": {"values": ["not-a-dict", {"type": "X"}]}}, - "not-an-entry", - ], - "tags": ["not-a-tag", {"key": "ok", "value": "v"}], - "user": "not-a-dict", - } - m = event_response_to_model(bad) - assert [e.type for e in m.exceptions] == ["X"] - assert m.tags == [("ok", "v")] +def test_minimal_event() -> None: + # a bare event (just a title) maps with everything else empty/absent + m = event_response_to_model({"title": "t"}) + assert m.title == "t" + assert m.exceptions == [] + assert m.breadcrumbs == [] + assert m.tags == [] + assert m.request is None assert m.user is None From e8f5803ce70d87a09fe40821169d5b76fb0542f9 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Tue, 14 Jul 2026 09:23:53 -0700 Subject: [PATCH 20/21] tests --- tests/sentry/issues/formatting/test_limits.py | 26 ------ tests/sentry/issues/formatting/test_models.py | 85 ------------------- 2 files changed, 111 deletions(-) delete mode 100644 tests/sentry/issues/formatting/test_limits.py delete mode 100644 tests/sentry/issues/formatting/test_models.py diff --git a/tests/sentry/issues/formatting/test_limits.py b/tests/sentry/issues/formatting/test_limits.py deleted file mode 100644 index 61fce467a36f..000000000000 --- a/tests/sentry/issues/formatting/test_limits.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from sentry.issues.formatting.limits import LIMITS_DEFAULT, Limits - - -def test_default_preset_matches_seer() -> None: - assert LIMITS_DEFAULT.max_exceptions_chars == 100_000 - assert LIMITS_DEFAULT.max_stacktrace_chars == 20_000 - assert LIMITS_DEFAULT.max_spans_chars == 5_000 - # uncapped in the default preset - assert LIMITS_DEFAULT.max_request_chars is None - assert LIMITS_DEFAULT.max_breadcrumbs_chars is None - assert LIMITS_DEFAULT.max_single_breadcrumb_chars is None - - -def test_count_caps_default() -> None: - # folded-in count caps (Seer hardcodes these) - assert LIMITS_DEFAULT.max_frames == 16 - assert LIMITS_DEFAULT.max_breadcrumbs == 10 - assert LIMITS_DEFAULT.max_threads == 8 - - -def test_limits_are_frozen() -> None: - limits = Limits() - with pytest.raises((AttributeError, TypeError)): - limits.max_frames = 5 # type: ignore[misc] diff --git a/tests/sentry/issues/formatting/test_models.py b/tests/sentry/issues/formatting/test_models.py deleted file mode 100644 index eda0eefe9403..000000000000 --- a/tests/sentry/issues/formatting/test_models.py +++ /dev/null @@ -1,85 +0,0 @@ -import pytest -from pydantic import ValidationError - -from sentry.issues.formatting.models import ( - Breadcrumb, - EventObject, - EvidenceSpan, - ExceptionDetails, - Frame, - RequestDetails, - Stacktrace, - ThreadDetails, - UserDetails, -) - - -def test_minimal_event_only_requires_title() -> None: - event = EventObject(title="ValueError: boom") - assert event.title == "ValueError: boom" - # collections default to empty, scalars to None - assert event.exceptions == [] - assert event.threads == [] - assert event.breadcrumbs == [] - assert event.tags == [] - assert event.contexts == {} - assert event.spans == [] - assert event.request is None - assert event.user is None - - -def test_title_is_required() -> None: - with pytest.raises(ValidationError): - EventObject() # type: ignore[call-arg] # missing required `title` is the point - - -def test_full_event_nests_submodels() -> None: - event = EventObject( - title="ValueError: boom", - message="boom happened", - exceptions=[ - ExceptionDetails( - type="ValueError", - value="boom", - is_handled=False, - stacktrace=Stacktrace( - frames=[ - Frame( - function="do_thing", - filename="app.py", - line_no=42, - context=[(42, "raise ValueError('boom')")], - in_app=True, - ) - ] - ), - ) - ], - threads=[ThreadDetails(id=0, name="main", crashed=True)], - breadcrumbs=[Breadcrumb(type="log", category="app", level="info", message="started")], - request=RequestDetails(method="GET", url="https://example.com", data={"q": "1"}), - tags=[("environment", "prod"), ("release", None)], - user=UserDetails(id="1", email="user@example.com"), - spans=[EvidenceSpan(op="db", description="SELECT 1", exclusive_time_ms=12.5)], - short_id="PROJ-1", - ) - - stacktrace = event.exceptions[0].stacktrace - assert stacktrace is not None - assert stacktrace.frames[0].line_no == 42 - assert event.exceptions[0].is_handled is False - assert event.threads[0].crashed is True - assert event.request is not None - assert event.request.method == "GET" - assert event.tags[0] == ("environment", "prod") - assert event.user is not None - assert event.user.email == "user@example.com" - assert event.spans[0].exclusive_time_ms == 12.5 - assert event.short_id == "PROJ-1" - - -def test_defaults_are_not_shared_between_instances() -> None: - a = EventObject(title="a") - b = EventObject(title="b") - a.exceptions.append(ExceptionDetails(type="X")) - assert b.exceptions == [] From ad80914b6f7cdf16b0273284fdbafead2ff6f3f0 Mon Sep 17 00:00:00 2001 From: Shayna Chambless <shayna.chambless@sentry.io> Date: Tue, 14 Jul 2026 09:38:02 -0700 Subject: [PATCH 21/21] bugbot --- src/sentry/issues/formatting/models.py | 1 - .../sentry/issues/formatting/test_adapter.py | 21 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/sentry/issues/formatting/models.py b/src/sentry/issues/formatting/models.py index 8a38c0715a51..de7dcb33c540 100644 --- a/src/sentry/issues/formatting/models.py +++ b/src/sentry/issues/formatting/models.py @@ -55,7 +55,6 @@ class Breadcrumb(BaseModel): level: str | None = None message: str | None = None data: dict[str, Any] | None = None - timestamp: str | None = None class RequestDetails(BaseModel): diff --git a/tests/sentry/issues/formatting/test_adapter.py b/tests/sentry/issues/formatting/test_adapter.py index 39abb41e413c..db6f83f29b4e 100644 --- a/tests/sentry/issues/formatting/test_adapter.py +++ b/tests/sentry/issues/formatting/test_adapter.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Any from sentry.issues.formatting.adapter import event_response_to_model @@ -178,6 +179,26 @@ def test_no_truncation_in_adapter() -> None: assert len(m.breadcrumbs) == 50 +def test_breadcrumb_datetime_timestamp_does_not_crash() -> None: + # the mixin passes the in-memory serialized event, where breadcrumb timestamps are + # datetime objects (not yet JSON strings) -- parsing must not choke on them + data = { + "title": "t", + "entries": [ + { + "type": "breadcrumbs", + "data": { + "values": [ + {"message": "hi", "timestamp": datetime(2024, 1, 1, tzinfo=timezone.utc)} + ] + }, + } + ], + } + m = event_response_to_model(data) + assert m.breadcrumbs[0].message == "hi" + + def test_minimal_event() -> None: # a bare event (just a title) maps with everything else empty/absent m = event_response_to_model({"title": "t"})