strawberry.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import functools
  2. import hashlib
  3. import warnings
  4. from inspect import isawaitable
  5. import sentry_sdk
  6. from sentry_sdk.consts import OP
  7. from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable
  8. from sentry_sdk.integrations.logging import ignore_logger
  9. from sentry_sdk.scope import should_send_default_pii
  10. from sentry_sdk.tracing import TransactionSource
  11. from sentry_sdk.utils import (
  12. capture_internal_exceptions,
  13. ensure_integration_enabled,
  14. event_from_exception,
  15. package_version,
  16. )
  17. try:
  18. from functools import cached_property
  19. except ImportError:
  20. # The strawberry integration requires Python 3.8+. functools.cached_property
  21. # was added in 3.8, so this check is technically not needed, but since this
  22. # is an auto-enabling integration, we might get to executing this import in
  23. # lower Python versions, so we need to deal with it.
  24. raise DidNotEnable("strawberry-graphql integration requires Python 3.8 or newer")
  25. try:
  26. from strawberry import Schema
  27. from strawberry.extensions import SchemaExtension
  28. from strawberry.extensions.tracing.utils import (
  29. should_skip_tracing as strawberry_should_skip_tracing,
  30. )
  31. from strawberry.http import async_base_view, sync_base_view
  32. except ImportError:
  33. raise DidNotEnable("strawberry-graphql is not installed")
  34. try:
  35. from strawberry.extensions.tracing import (
  36. SentryTracingExtension as StrawberrySentryAsyncExtension,
  37. SentryTracingExtensionSync as StrawberrySentrySyncExtension,
  38. )
  39. except ImportError:
  40. StrawberrySentryAsyncExtension = None
  41. StrawberrySentrySyncExtension = None
  42. from typing import TYPE_CHECKING
  43. if TYPE_CHECKING:
  44. from typing import Any, Callable, Generator, List, Optional
  45. from graphql import GraphQLError, GraphQLResolveInfo
  46. from strawberry.http import GraphQLHTTPResponse
  47. from strawberry.types import ExecutionContext
  48. from sentry_sdk._types import Event, EventProcessor
  49. ignore_logger("strawberry.execution")
  50. class StrawberryIntegration(Integration):
  51. identifier = "strawberry"
  52. origin = f"auto.graphql.{identifier}"
  53. def __init__(self, async_execution: "Optional[bool]" = None) -> None:
  54. if async_execution not in (None, False, True):
  55. raise ValueError(
  56. 'Invalid value for async_execution: "{}" (must be bool)'.format(
  57. async_execution
  58. )
  59. )
  60. self.async_execution = async_execution
  61. @staticmethod
  62. def setup_once() -> None:
  63. version = package_version("strawberry-graphql")
  64. _check_minimum_version(StrawberryIntegration, version, "strawberry-graphql")
  65. _patch_schema_init()
  66. _patch_views()
  67. def _patch_schema_init() -> None:
  68. old_schema_init = Schema.__init__
  69. @functools.wraps(old_schema_init)
  70. def _sentry_patched_schema_init(
  71. self: "Schema", *args: "Any", **kwargs: "Any"
  72. ) -> None:
  73. integration = sentry_sdk.get_client().get_integration(StrawberryIntegration)
  74. if integration is None:
  75. return old_schema_init(self, *args, **kwargs)
  76. extensions = kwargs.get("extensions") or []
  77. should_use_async_extension: "Optional[bool]" = None
  78. if integration.async_execution is not None:
  79. should_use_async_extension = integration.async_execution
  80. else:
  81. # try to figure it out ourselves
  82. should_use_async_extension = _guess_if_using_async(extensions)
  83. if should_use_async_extension is None:
  84. warnings.warn(
  85. "Assuming strawberry is running sync. If not, initialize the integration as StrawberryIntegration(async_execution=True).",
  86. stacklevel=2,
  87. )
  88. should_use_async_extension = False
  89. # remove the built in strawberry sentry extension, if present
  90. extensions = [
  91. extension
  92. for extension in extensions
  93. if extension
  94. not in (StrawberrySentryAsyncExtension, StrawberrySentrySyncExtension)
  95. ]
  96. # add our extension
  97. extensions.append(
  98. SentryAsyncExtension if should_use_async_extension else SentrySyncExtension
  99. )
  100. kwargs["extensions"] = extensions
  101. return old_schema_init(self, *args, **kwargs)
  102. Schema.__init__ = _sentry_patched_schema_init # type: ignore[method-assign]
  103. class SentryAsyncExtension(SchemaExtension):
  104. def __init__(
  105. self: "Any",
  106. *,
  107. execution_context: "Optional[ExecutionContext]" = None,
  108. ) -> None:
  109. if execution_context:
  110. self.execution_context = execution_context
  111. @cached_property
  112. def _resource_name(self) -> str:
  113. query_hash = self.hash_query(self.execution_context.query) # type: ignore
  114. if self.execution_context.operation_name:
  115. return "{}:{}".format(self.execution_context.operation_name, query_hash)
  116. return query_hash
  117. def hash_query(self, query: str) -> str:
  118. return hashlib.md5(query.encode("utf-8")).hexdigest()
  119. def on_operation(self) -> "Generator[None, None, None]":
  120. self._operation_name = self.execution_context.operation_name
  121. operation_type = "query"
  122. op = OP.GRAPHQL_QUERY
  123. if self.execution_context.query is None:
  124. self.execution_context.query = ""
  125. if self.execution_context.query.strip().startswith("mutation"):
  126. operation_type = "mutation"
  127. op = OP.GRAPHQL_MUTATION
  128. elif self.execution_context.query.strip().startswith("subscription"):
  129. operation_type = "subscription"
  130. op = OP.GRAPHQL_SUBSCRIPTION
  131. description = operation_type
  132. if self._operation_name:
  133. description += " {}".format(self._operation_name)
  134. sentry_sdk.add_breadcrumb(
  135. category="graphql.operation",
  136. data={
  137. "operation_name": self._operation_name,
  138. "operation_type": operation_type,
  139. },
  140. )
  141. scope = sentry_sdk.get_isolation_scope()
  142. event_processor = _make_request_event_processor(self.execution_context)
  143. scope.add_event_processor(event_processor)
  144. self.graphql_span = sentry_sdk.start_span(
  145. op=op,
  146. name=description,
  147. origin=StrawberryIntegration.origin,
  148. )
  149. self.graphql_span.__enter__()
  150. self.graphql_span.set_data("graphql.operation.type", operation_type)
  151. self.graphql_span.set_data("graphql.operation.name", self._operation_name)
  152. self.graphql_span.set_data("graphql.document", self.execution_context.query)
  153. self.graphql_span.set_data("graphql.resource_name", self._resource_name)
  154. yield
  155. transaction = self.graphql_span.containing_transaction
  156. if transaction and self.execution_context.operation_name:
  157. transaction.name = self.execution_context.operation_name
  158. transaction.source = TransactionSource.COMPONENT
  159. transaction.op = op
  160. self.graphql_span.__exit__(None, None, None)
  161. def on_validate(self) -> "Generator[None, None, None]":
  162. self.validation_span = self.graphql_span.start_child(
  163. op=OP.GRAPHQL_VALIDATE,
  164. name="validation",
  165. origin=StrawberryIntegration.origin,
  166. )
  167. yield
  168. self.validation_span.finish()
  169. def on_parse(self) -> "Generator[None, None, None]":
  170. self.parsing_span = self.graphql_span.start_child(
  171. op=OP.GRAPHQL_PARSE,
  172. name="parsing",
  173. origin=StrawberryIntegration.origin,
  174. )
  175. yield
  176. self.parsing_span.finish()
  177. def should_skip_tracing(
  178. self,
  179. _next: "Callable[[Any, GraphQLResolveInfo, Any, Any], Any]",
  180. info: "GraphQLResolveInfo",
  181. ) -> bool:
  182. return strawberry_should_skip_tracing(_next, info)
  183. async def _resolve(
  184. self,
  185. _next: "Callable[[Any, GraphQLResolveInfo, Any, Any], Any]",
  186. root: "Any",
  187. info: "GraphQLResolveInfo",
  188. *args: str,
  189. **kwargs: "Any",
  190. ) -> "Any":
  191. result = _next(root, info, *args, **kwargs)
  192. if isawaitable(result):
  193. result = await result
  194. return result
  195. async def resolve(
  196. self,
  197. _next: "Callable[[Any, GraphQLResolveInfo, Any, Any], Any]",
  198. root: "Any",
  199. info: "GraphQLResolveInfo",
  200. *args: str,
  201. **kwargs: "Any",
  202. ) -> "Any":
  203. if self.should_skip_tracing(_next, info):
  204. return await self._resolve(_next, root, info, *args, **kwargs)
  205. field_path = "{}.{}".format(info.parent_type, info.field_name)
  206. with self.graphql_span.start_child(
  207. op=OP.GRAPHQL_RESOLVE,
  208. name="resolving {}".format(field_path),
  209. origin=StrawberryIntegration.origin,
  210. ) as span:
  211. span.set_data("graphql.field_name", info.field_name)
  212. span.set_data("graphql.parent_type", info.parent_type.name)
  213. span.set_data("graphql.field_path", field_path)
  214. span.set_data("graphql.path", ".".join(map(str, info.path.as_list())))
  215. return await self._resolve(_next, root, info, *args, **kwargs)
  216. class SentrySyncExtension(SentryAsyncExtension):
  217. def resolve(
  218. self,
  219. _next: "Callable[[Any, Any, Any, Any], Any]",
  220. root: "Any",
  221. info: "GraphQLResolveInfo",
  222. *args: str,
  223. **kwargs: "Any",
  224. ) -> "Any":
  225. if self.should_skip_tracing(_next, info):
  226. return _next(root, info, *args, **kwargs)
  227. field_path = "{}.{}".format(info.parent_type, info.field_name)
  228. with self.graphql_span.start_child(
  229. op=OP.GRAPHQL_RESOLVE,
  230. name="resolving {}".format(field_path),
  231. origin=StrawberryIntegration.origin,
  232. ) as span:
  233. span.set_data("graphql.field_name", info.field_name)
  234. span.set_data("graphql.parent_type", info.parent_type.name)
  235. span.set_data("graphql.field_path", field_path)
  236. span.set_data("graphql.path", ".".join(map(str, info.path.as_list())))
  237. return _next(root, info, *args, **kwargs)
  238. def _patch_views() -> None:
  239. old_async_view_handle_errors = async_base_view.AsyncBaseHTTPView._handle_errors
  240. old_sync_view_handle_errors = sync_base_view.SyncBaseHTTPView._handle_errors
  241. def _sentry_patched_async_view_handle_errors(
  242. self: "Any", errors: "List[GraphQLError]", response_data: "GraphQLHTTPResponse"
  243. ) -> None:
  244. old_async_view_handle_errors(self, errors, response_data)
  245. _sentry_patched_handle_errors(self, errors, response_data)
  246. def _sentry_patched_sync_view_handle_errors(
  247. self: "Any", errors: "List[GraphQLError]", response_data: "GraphQLHTTPResponse"
  248. ) -> None:
  249. old_sync_view_handle_errors(self, errors, response_data)
  250. _sentry_patched_handle_errors(self, errors, response_data)
  251. @ensure_integration_enabled(StrawberryIntegration)
  252. def _sentry_patched_handle_errors(
  253. self: "Any", errors: "List[GraphQLError]", response_data: "GraphQLHTTPResponse"
  254. ) -> None:
  255. if not errors:
  256. return
  257. scope = sentry_sdk.get_isolation_scope()
  258. event_processor = _make_response_event_processor(response_data)
  259. scope.add_event_processor(event_processor)
  260. with capture_internal_exceptions():
  261. for error in errors:
  262. event, hint = event_from_exception(
  263. error,
  264. client_options=sentry_sdk.get_client().options,
  265. mechanism={
  266. "type": StrawberryIntegration.identifier,
  267. "handled": False,
  268. },
  269. )
  270. sentry_sdk.capture_event(event, hint=hint)
  271. async_base_view.AsyncBaseHTTPView._handle_errors = ( # type: ignore[method-assign]
  272. _sentry_patched_async_view_handle_errors
  273. )
  274. sync_base_view.SyncBaseHTTPView._handle_errors = ( # type: ignore[method-assign]
  275. _sentry_patched_sync_view_handle_errors
  276. )
  277. def _make_request_event_processor(
  278. execution_context: "ExecutionContext",
  279. ) -> "EventProcessor":
  280. def inner(event: "Event", hint: "dict[str, Any]") -> "Event":
  281. with capture_internal_exceptions():
  282. if should_send_default_pii():
  283. request_data = event.setdefault("request", {})
  284. request_data["api_target"] = "graphql"
  285. if not request_data.get("data"):
  286. data: "dict[str, Any]" = {"query": execution_context.query}
  287. if execution_context.variables:
  288. data["variables"] = execution_context.variables
  289. if execution_context.operation_name:
  290. data["operationName"] = execution_context.operation_name
  291. request_data["data"] = data
  292. else:
  293. try:
  294. del event["request"]["data"]
  295. except (KeyError, TypeError):
  296. pass
  297. return event
  298. return inner
  299. def _make_response_event_processor(
  300. response_data: "GraphQLHTTPResponse",
  301. ) -> "EventProcessor":
  302. def inner(event: "Event", hint: "dict[str, Any]") -> "Event":
  303. with capture_internal_exceptions():
  304. if should_send_default_pii():
  305. contexts = event.setdefault("contexts", {})
  306. contexts["response"] = {"data": response_data}
  307. return event
  308. return inner
  309. def _guess_if_using_async(extensions: "List[SchemaExtension]") -> "Optional[bool]":
  310. if StrawberrySentryAsyncExtension in extensions:
  311. return True
  312. elif StrawberrySentrySyncExtension in extensions:
  313. return False
  314. return None