Skip to content
Open
92 changes: 92 additions & 0 deletions src/sentry/issues/formatting/adapter.py
Original file line number Diff line number Diff line change
@@ -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
)
Comment thread
cursor[bot] marked this conversation as resolved.


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"],
Comment thread
shayna-ch marked this conversation as resolved.
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 []],
)
Comment thread
cursor[bot] marked this conversation as resolved.
72 changes: 72 additions & 0 deletions src/sentry/issues/formatting/formatter.py
Original file line number Diff line number Diff line change
@@ -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"<code>{text}</code>"
Comment thread
shayna-ch marked this conversation as resolved.
Comment thread
shayna-ch marked this conversation as resolved.
28 changes: 28 additions & 0 deletions src/sentry/issues/formatting/limits.py
Original file line number Diff line number Diff line change
@@ -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,
)
114 changes: 114 additions & 0 deletions src/sentry/issues/formatting/models.py
Original file line number Diff line number Diff line change
@@ -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), ...]
Comment thread
shayna-ch marked this conversation as resolved.
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
Loading
Loading