schema.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376
  1. import logging
  2. import typing
  3. import uuid
  4. from datetime import datetime
  5. from typing import Annotated, Any, Literal, Optional, Union
  6. from urllib.parse import parse_qs
  7. from django.utils.timezone import now
  8. from ninja import Field
  9. from ninja import Schema as BaseSchema
  10. from pydantic import (
  11. AliasChoices,
  12. BeforeValidator,
  13. RootModel,
  14. ValidationError,
  15. WrapValidator,
  16. field_validator,
  17. model_validator,
  18. )
  19. from apps.issue_events.constants import IssueEventType
  20. from ..shared.schema.contexts import ContextsSchema
  21. from ..shared.schema.event import (
  22. BaseIssueEvent,
  23. BaseRequest,
  24. EventBreadcrumb,
  25. ListKeyValue,
  26. )
  27. from ..shared.schema.user import EventUser
  28. from ..shared.schema.utils import invalid_to_none
  29. logger = logging.getLogger(__name__)
  30. CoercedStr = Annotated[
  31. str, BeforeValidator(lambda v: str(v) if isinstance(v, bool) else v)
  32. ]
  33. """
  34. Coerced Str that will coerce bool to str when found
  35. """
  36. class Schema(BaseSchema):
  37. """Schema configuration for all event ingest schemas"""
  38. class Config(BaseSchema.Config):
  39. coerce_numbers_to_str = True # Lax is best for ingest
  40. class Signal(Schema):
  41. number: int
  42. code: Optional[int]
  43. name: Optional[str]
  44. code_name: Optional[str]
  45. class MachException(Schema):
  46. number: int
  47. code: int
  48. subcode: int
  49. name: Optional[str]
  50. class NSError(Schema):
  51. code: int
  52. domain: str
  53. class Errno(Schema):
  54. number: int
  55. name: Optional[str]
  56. class MechanismMeta(Schema):
  57. signal: Optional[Signal] = None
  58. match_exception: Optional[MachException] = None
  59. ns_error: Optional[NSError] = None
  60. errno: Optional[Errno] = None
  61. class ExceptionMechanism(Schema):
  62. type: str
  63. description: Optional[str] = None
  64. help_link: Optional[str] = None
  65. handled: Optional[bool] = None
  66. synthetic: Optional[bool] = None
  67. meta: Optional[dict] = None
  68. data: Optional[dict] = None
  69. class StackTraceFrame(Schema):
  70. filename: Optional[str] = None
  71. function: Optional[str] = None
  72. raw_function: Optional[str] = None
  73. module: Optional[str] = None
  74. lineno: Optional[int] = None
  75. colno: Optional[int] = None
  76. abs_path: Optional[str] = None
  77. context_line: Optional[str] = None
  78. pre_context: Optional[list[str]] = None
  79. post_context: Optional[list[str]] = None
  80. source_link: Optional[str] = None
  81. in_app: Optional[bool] = None
  82. stack_start: Optional[bool] = None
  83. vars: Optional[dict[str, Union[str, dict, list]]] = None
  84. instruction_addr: Optional[str] = None
  85. addr_mode: Optional[str] = None
  86. symbol_addr: Optional[str] = None
  87. image_addr: Optional[str] = None
  88. package: Optional[str] = None
  89. platform: Optional[str] = None
  90. class StackTrace(Schema):
  91. frames: list[StackTraceFrame]
  92. registers: Optional[dict[str, str]] = None
  93. class EventException(Schema):
  94. type: str
  95. value: Annotated[Optional[str], WrapValidator(invalid_to_none)]
  96. module: Optional[str] = None
  97. thread_id: Optional[str] = None
  98. mechanism: Optional[ExceptionMechanism] = None
  99. stacktrace: Optional[StackTrace] = None
  100. class ValueEventException(Schema):
  101. values: list[EventException]
  102. class EventMessage(Schema):
  103. formatted: str = Field(max_length=8192, default="")
  104. message: Optional[str] = None
  105. params: Optional[Union[list[str], dict[str, str]]] = None
  106. @model_validator(mode="after")
  107. def set_formatted(self) -> "EventMessage":
  108. """
  109. When the EventMessage formatted string is not set,
  110. attempt to set it based on message and params interpolation
  111. """
  112. if not self.formatted and self.message:
  113. params = self.params
  114. if isinstance(params, list) and params is not None:
  115. self.formatted = self.message % tuple(params)
  116. elif isinstance(params, dict):
  117. self.formatted = self.message.format(**params)
  118. return self
  119. class EventTemplate(Schema):
  120. lineno: int
  121. abs_path: Optional[str] = None
  122. filename: str
  123. context_line: str
  124. pre_context: Optional[list[str]] = None
  125. post_context: Optional[list[str]] = None
  126. class ValueEventBreadcrumb(Schema):
  127. values: list[EventBreadcrumb]
  128. class ClientSDKPackage(Schema):
  129. name: Optional[str] = None
  130. version: Optional[str] = None
  131. class ClientSDKInfo(Schema):
  132. integrations: Optional[list[Optional[str]]] = None
  133. name: Optional[str]
  134. packages: Optional[list[ClientSDKPackage]] = None
  135. version: Optional[str]
  136. class RequestHeaders(Schema):
  137. content_type: Optional[str]
  138. class RequestEnv(Schema):
  139. remote_addr: Optional[str]
  140. QueryString = Union[str, ListKeyValue, dict[str, Optional[str]]]
  141. """Raw URL querystring, list, or dict"""
  142. KeyValueFormat = Union[list[list[Optional[str]]], dict[str, Optional[CoercedStr]]]
  143. """
  144. key-values in list or dict format. Example {browser: firefox} or [[browser, firefox]]
  145. """
  146. class IngestRequest(BaseRequest):
  147. headers: Optional[KeyValueFormat] = None
  148. query_string: Optional[QueryString] = None
  149. @field_validator("headers", mode="before")
  150. @classmethod
  151. def fix_non_standard_headers(cls, v):
  152. """
  153. Fix non-documented format used by PHP Sentry Client
  154. Convert {"Foo": ["bar"]} into {"Foo: "bar"}
  155. """
  156. if isinstance(v, dict):
  157. return {
  158. key: value[0] if isinstance(value, list) else value
  159. for key, value in v.items()
  160. }
  161. return v
  162. @field_validator("query_string", "headers")
  163. @classmethod
  164. def prefer_list_key_value(
  165. cls, v: Optional[Union[QueryString, KeyValueFormat]]
  166. ) -> Optional[ListKeyValue]:
  167. """Store all querystring, header formats in a list format"""
  168. result: Optional[ListKeyValue] = None
  169. if isinstance(v, str) and v: # It must be a raw querystring, parse it
  170. qs = parse_qs(v)
  171. result = [[key, value] for key, values in qs.items() for value in values]
  172. elif isinstance(v, dict): # Convert dict to list
  173. result = [[key, value] for key, value in v.items()]
  174. elif isinstance(v, list): # Normalize list (throw out any weird data)
  175. result = [item[:2] for item in v if len(item) >= 2]
  176. if result:
  177. # Remove empty and any key called "Cookie" which could be sensitive data
  178. entry_to_remove = ["Cookie", ""]
  179. return sorted(
  180. [entry for entry in result if entry != entry_to_remove],
  181. key=lambda x: (x[0], x[1]),
  182. )
  183. return result
  184. class IngestIssueEvent(BaseIssueEvent):
  185. timestamp: datetime = Field(default_factory=now)
  186. level: Optional[str] = "error"
  187. logentry: Optional[EventMessage] = None
  188. logger: Optional[str] = None
  189. transaction: Optional[str] = Field(
  190. validation_alias=AliasChoices("transaction", "culprit"), default=None
  191. )
  192. server_name: Optional[str] = None
  193. release: Optional[str] = None
  194. dist: Optional[str] = None
  195. tags: Optional[KeyValueFormat] = None
  196. environment: Optional[str] = None
  197. modules: Optional[dict[str, Optional[str]]] = None
  198. extra: Optional[dict[str, Any]] = None
  199. fingerprint: Optional[list[str]] = None
  200. errors: Optional[list[Any]] = None
  201. exception: Optional[Union[list[EventException], ValueEventException]] = None
  202. message: Optional[Union[str, EventMessage]] = None
  203. template: Optional[EventTemplate] = None
  204. breadcrumbs: Optional[Union[list[EventBreadcrumb], ValueEventBreadcrumb]] = None
  205. sdk: Optional[ClientSDKInfo] = None
  206. request: Optional[IngestRequest] = None
  207. contexts: Optional[ContextsSchema] = None
  208. user: Optional[EventUser] = None
  209. @field_validator("tags")
  210. @classmethod
  211. def prefer_dict(
  212. cls, v: Optional[KeyValueFormat]
  213. ) -> Optional[dict[str, Optional[str]]]:
  214. if isinstance(v, list):
  215. return {key: value for key, value in v if key is not None}
  216. return v
  217. class EventIngestSchema(IngestIssueEvent):
  218. event_id: uuid.UUID
  219. class EnvelopeHeaderSchema(Schema):
  220. event_id: uuid.UUID
  221. dsn: Optional[str] = None
  222. sdk: Optional[ClientSDKInfo] = None
  223. sent_at: datetime = Field(default_factory=now)
  224. SupportedItemType = Literal["transaction", "event"]
  225. SUPPORTED_ITEMS = typing.get_args(SupportedItemType)
  226. class ItemHeaderSchema(Schema):
  227. content_type: Optional[str]
  228. type: SupportedItemType
  229. length: Optional[int]
  230. class EnvelopeSchema(RootModel[list[dict[str, Any]]]):
  231. root: list[dict[str, Any]]
  232. _header: EnvelopeHeaderSchema
  233. _items: list[tuple[ItemHeaderSchema, IngestIssueEvent]] = []
  234. @model_validator(mode="after")
  235. def validate_envelope(self) -> "EnvelopeSchema":
  236. data = self.root
  237. try:
  238. header = data.pop(0)
  239. except IndexError:
  240. raise ValidationError([{"message": "Envelope is empty"}])
  241. self._header = EnvelopeHeaderSchema(**header)
  242. while len(data) >= 2:
  243. item_header_data = data.pop(0)
  244. if item_header_data.get("type", None) not in SUPPORTED_ITEMS:
  245. continue
  246. item_header = ItemHeaderSchema(**item_header_data)
  247. if item_header.type == "event":
  248. try:
  249. item = IngestIssueEvent(**data.pop(0))
  250. except ValidationError as err:
  251. logger.warning("Envelope Event item invalid", exc_info=True)
  252. raise err
  253. self._items.append((item_header, item))
  254. return self
  255. class CSPReportSchema(Schema):
  256. """
  257. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only#violation_report_syntax
  258. """
  259. blocked_uri: str = Field(alias="blocked-uri")
  260. disposition: Literal["enforce", "report"] = Field(alias="disposition")
  261. document_uri: str = Field(alias="document-uri")
  262. effective_directive: str = Field(alias="effective-directive")
  263. original_policy: Optional[str] = Field(alias="original-policy")
  264. script_sample: Optional[str] = Field(alias="script-sample", default=None)
  265. status_code: Optional[int] = Field(alias="status-code")
  266. line_number: Optional[int] = None
  267. column_number: Optional[int] = None
  268. class SecuritySchema(Schema):
  269. csp_report: CSPReportSchema = Field(alias="csp-report")
  270. ## Normalized Interchange Issue Events
  271. class IssueEventSchema(IngestIssueEvent):
  272. """
  273. Event storage and interchange format
  274. Used in json view and celery interchange
  275. Don't use this for api intake
  276. """
  277. type: Literal[IssueEventType.DEFAULT] = IssueEventType.DEFAULT
  278. class ErrorIssueEventSchema(IngestIssueEvent):
  279. type: Literal[IssueEventType.ERROR] = IssueEventType.ERROR
  280. class CSPIssueEventSchema(IngestIssueEvent):
  281. type: Literal[IssueEventType.CSP] = IssueEventType.CSP
  282. csp: CSPReportSchema
  283. class InterchangeIssueEvent(Schema):
  284. """Normalized wrapper around issue event. Event should not contain repeat information."""
  285. event_id: uuid.UUID = Field(default_factory=uuid.uuid4)
  286. project_id: int
  287. received: datetime = Field(default_factory=now)
  288. payload: Union[
  289. IssueEventSchema, ErrorIssueEventSchema, CSPIssueEventSchema
  290. ] = Field(discriminator="type")