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" + + def field(self, key: str, value: str) -> str: + tag = slug(key) + return f"<{tag}>{value}" + + 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 == "\nValueError: boom\n" + + +def test_empty_sections_are_skipped() -> None: + event = EventObject(title="t") + 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=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=LIMITS_DEFAULT) + 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") == "```\nSELECT 1\n```" + + xml = XmlFormatter() + assert xml.field("Handled", "No") == "No" + assert xml.code_block("SELECT 1") == "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("\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_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)] + ) + out = spans_section(event, MD, LIMITS_DEFAULT) + 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", + "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_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