diff --git a/src/sentry/issues/formatting/adapter.py b/src/sentry/issues/formatting/adapter.py
new file mode 100644
index 000000000000..8807d841700f
--- /dev/null
+++ b/src/sentry/issues/formatting/adapter.py
@@ -0,0 +1,92 @@
+"""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
+from typing import Any
+
+from sentry.issues.formatting.models import (
+ Breadcrumb,
+ EventObject,
+ EvidenceSpan,
+ ExceptionDetails,
+ RequestDetails,
+ Stacktrace,
+ ThreadDetails,
+ UserDetails,
+)
+
+
+def _entries_by_type(data: Mapping[str, Any]) -> dict[str, Any]:
+ 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 that's where the frames are
+ for key in ("stacktrace", "rawStacktrace"):
+ st = v.get(key)
+ if st:
+ parsed = Stacktrace.parse_obj(st)
+ if parsed.frames:
+ return parsed
+ return None
+
+
+def _exception(v: Mapping[str, Any]) -> ExceptionDetails:
+ return ExceptionDetails(
+ type=v.get("type"),
+ value=v.get("value"),
+ stacktrace=_best_stacktrace(v),
+ is_handled=(v.get("mechanism") or {}).get("handled"), # nested under mechanism
+ )
+
+
+def _thread(v: Mapping[str, Any]) -> ThreadDetails:
+ thread = ThreadDetails.parse_obj(v)
+ thread.stacktrace = _best_stacktrace(v) # same raw/processed fallback as exceptions
+ return thread
+
+
+def _tags(data: Mapping[str, Any]) -> tuple[list[tuple[str, str | None]], str | None]:
+ 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:
+ entries = _entries_by_type(data)
+ tags, transaction_name = _tags(data)
+
+ # 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"),
+ title=data["title"],
+ message=message,
+ culprit=data.get("culprit"),
+ platform=data.get("platform"),
+ transaction_name=transaction_name,
+ 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.parse_obj(v) for v in _values(entries.get("breadcrumbs"))],
+ request=RequestDetails.parse_obj(request) if request else None,
+ tags=tags,
+ contexts=data.get("contexts") or {},
+ user=UserDetails.parse_obj(user) if user else None,
+ spans=[EvidenceSpan.parse_obj(s) for s in entries.get("spans") or []],
+ )
diff --git a/src/sentry/issues/formatting/formatter.py b/src/sentry/issues/formatting/formatter.py
new file mode 100644
index 000000000000..74c3d895f9bc
--- /dev/null
+++ b/src/sentry/issues/formatting/formatter.py
@@ -0,0 +1,72 @@
+"""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
+
+import logging
+import re
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Sequence
+
+from sentry.issues.formatting.limits import Limits
+from sentry.issues.formatting.models import EventObject
+
+logger = logging.getLogger(__name__)
+
+SectionFn = Callable[["EventObject", "Formatter", Limits], 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: Limits) -> 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) -> 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) -> str:
+ return f"```\n{text}\n```"
+
+
+class XmlFormatter(Formatter):
+ def block(self, title: str, body: str) -> str:
+ tag = slug(title)
+ return f"<{tag}>\n{body}\n{tag}>"
+
+ def field(self, key: str, value: str) -> str:
+ tag = slug(key)
+ return f"<{tag}>{value}{tag}>"
+
+ def code_block(self, text: str) -> str:
+ return f"{text}"
diff --git a/src/sentry/issues/formatting/limits.py b/src/sentry/issues/formatting/limits.py
new file mode 100644
index 000000000000..dac617395c3c
--- /dev/null
+++ b/src/sentry/issues/formatting/limits.py
@@ -0,0 +1,28 @@
+"""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``/``max_threads`` are count caps.
+"""
+
+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
+ max_threads: int = 8
+
+
+LIMITS_DEFAULT = Limits(
+ max_exceptions_chars=100_000,
+ max_stacktrace_chars=20_000,
+ max_spans_chars=5_000,
+)
diff --git a/src/sentry/issues/formatting/models.py b/src/sentry/issues/formatting/models.py
new file mode 100644
index 000000000000..de7dcb33c540
--- /dev/null
+++ b/src/sentry/issues/formatting/models.py
@@ -0,0 +1,114 @@
+"""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
+
+from datetime import datetime
+from typing import Any
+
+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 = Field(None, alias="absPath")
+ module: str | None = None
+ package: str | 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 = Field(False, alias="inApp")
+
+
+class Stacktrace(BaseModel):
+ frames: list[Frame] = []
+
+
+class ExceptionDetails(BaseModel):
+ type: str | None = None
+ value: str | None = None
+ 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
+
+
+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):
+ class Config:
+ allow_population_by_field_name = True
+
+ op: str | None = None
+ description: str | 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 = Field(None, alias="ipAddress")
+
+
+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] = []
+ 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] = []
+
+ # 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/src/sentry/issues/formatting/sections.py b/src/sentry/issues/formatting/sections.py
new file mode 100644
index 000000000000..fd22bb1144f6
--- /dev/null
+++ b/src/sentry/issues/formatting/sections.py
@@ -0,0 +1,221 @@
+"""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 collections.abc import Mapping, Sequence
+from typing import Any, Literal
+
+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
+
+
+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 _contains_filtered(value: Any) -> bool:
+ return "[Filtered]" in str(value)
+
+
+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)
+
+
+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}"
+ if line:
+ 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:
+ blocks: list[str] = []
+ 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"))
+ 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 ""
+ return fmt.block("Threads", "\n\n".join(blocks))
+
+
+def spans_section(model: EventObject, fmt: Formatter, limits: Limits) -> str:
+ 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 ""
+ 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))
+
+
+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/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_adapter.py b/tests/sentry/issues/formatting/test_adapter.py
new file mode 100644
index 000000000000..db6f83f29b4e
--- /dev/null
+++ b/tests/sentry/issues/formatting/test_adapter.py
@@ -0,0 +1,210 @@
+from datetime import datetime, timezone
+from typing import Any
+
+from sentry.issues.formatting.adapter import event_response_to_model
+
+
+def _serialized_event() -> dict[str, Any]:
+ 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_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]
+ 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
+ assert frame.abs_path == "/srv/app.py" # absPath -> abs_path
+ 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"
+ 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
+
+
+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_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"})
+ assert m.title == "t"
+ assert m.exceptions == []
+ assert m.breadcrumbs == []
+ assert m.tags == []
+ assert m.request is None
+ assert m.user is None
diff --git a/tests/sentry/issues/formatting/test_formatter.py b/tests/sentry/issues/formatting/test_formatter.py
new file mode 100644
index 000000000000..7b435ec4ce7f
--- /dev/null
+++ b/tests/sentry/issues/formatting/test_formatter.py
@@ -0,0 +1,65 @@
+from sentry.issues.formatting.formatter import (
+ Formatter,
+ MarkdownFormatter,
+ XmlFormatter,
+ slug,
+)
+from sentry.issues.formatting.limits import LIMITS_DEFAULT
+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=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=LIMITS_DEFAULT)
+ assert out == "
SELECT 1"
+
+
+def test_slug() -> None:
+ assert slug("HTTP Request") == "http_request"
+ assert slug("Exception") == "exception"
diff --git a/tests/sentry/issues/formatting/test_sections.py b/tests/sentry/issues/formatting/test_sections.py
new file mode 100644
index 000000000000..1edcd7919894
--- /dev/null
+++ b/tests/sentry/issues/formatting/test_sections.py
@@ -0,0 +1,263 @@
+import dataclasses
+from typing import Any
+
+import pytest
+
+from sentry.issues.formatting.formatter import MarkdownFormatter, XmlFormatter
+from sentry.issues.formatting.limits import LIMITS_DEFAULT
+from sentry.issues.formatting.models import (
+ Breadcrumb,
+ EventObject,
+ EvidenceSpan,
+ ExceptionDetails,
+ Frame,
+ RequestDetails,
+ Stacktrace,
+ ThreadDetails,
+ UserDetails,
+)
+from sentry.issues.formatting.sections import (
+ EVENT_SECTIONS,
+ breadcrumbs_section,
+ exceptions_section,
+ format_issue,
+ message_section,
+ request_section,
+ spans_section,
+ tags_section,
+ threads_section,
+ title_section,
+ user_section,
+)
+
+MD = MarkdownFormatter()
+
+
+def _event_with_exception(**exc_kwargs: Any) -> 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("