Skip to content

Commit 5199d54

Browse files
Capture GraphQL client errors (#2243)
Inspect requests done with our HTTP client integrations (stdlib, httpx, aiohttp), identify GraphQL requests, and capture a specialized error event if the response from the server contains a non-empty errors array. Closes #2198 --------- Co-authored-by: Anton Pirker <[email protected]>
1 parent 4131b5f commit 5199d54

File tree

10 files changed

+1571
-44
lines changed

10 files changed

+1571
-44
lines changed

sentry_sdk/integrations/aiohttp.py

Lines changed: 156 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
import json
12
import sys
23
import weakref
34

5+
try:
6+
from urllib.parse import parse_qsl
7+
except ImportError:
8+
from urlparse import parse_qsl # type: ignore
9+
410
from sentry_sdk.api import continue_trace
511
from sentry_sdk._compat import reraise
612
from sentry_sdk.consts import OP, SPANDATA
7-
from sentry_sdk.hub import Hub
13+
from sentry_sdk.hub import Hub, _should_send_default_pii
814
from sentry_sdk.integrations import Integration, DidNotEnable
915
from sentry_sdk.integrations.logging import ignore_logger
1016
from sentry_sdk.sessions import auto_session_tracking
@@ -29,14 +35,17 @@
2935
CONTEXTVARS_ERROR_MESSAGE,
3036
SENSITIVE_DATA_SUBSTITUTE,
3137
AnnotatedValue,
38+
SentryGraphQLClientError,
39+
_get_graphql_operation_name,
40+
_get_graphql_operation_type,
3241
)
3342

3443
try:
3544
import asyncio
3645

3746
from aiohttp import __version__ as AIOHTTP_VERSION
38-
from aiohttp import ClientSession, TraceConfig
39-
from aiohttp.web import Application, HTTPException, UrlDispatcher
47+
from aiohttp import ClientSession, ContentTypeError, TraceConfig
48+
from aiohttp.web import Application, HTTPException, UrlDispatcher, Response
4049
except ImportError:
4150
raise DidNotEnable("AIOHTTP not installed")
4251

@@ -45,7 +54,11 @@
4554
if TYPE_CHECKING:
4655
from aiohttp.web_request import Request
4756
from aiohttp.abc import AbstractMatchInfo
48-
from aiohttp import TraceRequestStartParams, TraceRequestEndParams
57+
from aiohttp import (
58+
TraceRequestStartParams,
59+
TraceRequestEndParams,
60+
TraceRequestChunkSentParams,
61+
)
4962
from types import SimpleNamespace
5063
from typing import Any
5164
from typing import Dict
@@ -64,15 +77,17 @@
6477
class AioHttpIntegration(Integration):
6578
identifier = "aiohttp"
6679

67-
def __init__(self, transaction_style="handler_name"):
68-
# type: (str) -> None
80+
def __init__(self, transaction_style="handler_name", capture_graphql_errors=True):
81+
# type: (str, bool) -> None
6982
if transaction_style not in TRANSACTION_STYLE_VALUES:
7083
raise ValueError(
7184
"Invalid value for transaction_style: %s (must be in %s)"
7285
% (transaction_style, TRANSACTION_STYLE_VALUES)
7386
)
7487
self.transaction_style = transaction_style
7588

89+
self.capture_graphql_errors = capture_graphql_errors
90+
7691
@staticmethod
7792
def setup_once():
7893
# type: () -> None
@@ -111,7 +126,7 @@ async def sentry_app_handle(self, request, *args, **kwargs):
111126
# create a task to wrap each request.
112127
with hub.configure_scope() as scope:
113128
scope.clear_breadcrumbs()
114-
scope.add_event_processor(_make_request_processor(weak_request))
129+
scope.add_event_processor(_make_server_processor(weak_request))
115130

116131
transaction = continue_trace(
117132
request.headers,
@@ -139,6 +154,7 @@ async def sentry_app_handle(self, request, *args, **kwargs):
139154
reraise(*_capture_exception(hub))
140155

141156
transaction.set_http_status(response.status)
157+
142158
return response
143159

144160
Application._handle = sentry_app_handle
@@ -198,7 +214,8 @@ def create_trace_config():
198214
async def on_request_start(session, trace_config_ctx, params):
199215
# type: (ClientSession, SimpleNamespace, TraceRequestStartParams) -> None
200216
hub = Hub.current
201-
if hub.get_integration(AioHttpIntegration) is None:
217+
integration = hub.get_integration(AioHttpIntegration)
218+
if integration is None:
202219
return
203220

204221
method = params.method.upper()
@@ -233,28 +250,95 @@ async def on_request_start(session, trace_config_ctx, params):
233250
params.headers[key] = value
234251

235252
trace_config_ctx.span = span
253+
trace_config_ctx.is_graphql_request = params.url.path == "/graphql"
254+
255+
if integration.capture_graphql_errors and trace_config_ctx.is_graphql_request:
256+
trace_config_ctx.request_headers = params.headers
257+
258+
async def on_request_chunk_sent(session, trace_config_ctx, params):
259+
# type: (ClientSession, SimpleNamespace, TraceRequestChunkSentParams) -> None
260+
integration = Hub.current.get_integration(AioHttpIntegration)
261+
if integration is None:
262+
return
263+
264+
if integration.capture_graphql_errors and trace_config_ctx.is_graphql_request:
265+
trace_config_ctx.request_body = None
266+
with capture_internal_exceptions():
267+
try:
268+
trace_config_ctx.request_body = json.loads(params.chunk)
269+
except json.JSONDecodeError:
270+
return
236271

237272
async def on_request_end(session, trace_config_ctx, params):
238273
# type: (ClientSession, SimpleNamespace, TraceRequestEndParams) -> None
239-
if trace_config_ctx.span is None:
274+
hub = Hub.current
275+
integration = hub.get_integration(AioHttpIntegration)
276+
if integration is None:
240277
return
241278

242-
span = trace_config_ctx.span
243-
span.set_http_status(int(params.response.status))
244-
span.set_data("reason", params.response.reason)
245-
span.finish()
279+
response = params.response
280+
281+
if trace_config_ctx.span is not None:
282+
span = trace_config_ctx.span
283+
span.set_http_status(int(response.status))
284+
span.set_data("reason", response.reason)
285+
286+
if (
287+
integration.capture_graphql_errors
288+
and trace_config_ctx.is_graphql_request
289+
and response.method in ("GET", "POST")
290+
and response.status == 200
291+
):
292+
with hub.configure_scope() as scope:
293+
with capture_internal_exceptions():
294+
try:
295+
response_content = await response.json()
296+
except ContentTypeError:
297+
pass
298+
else:
299+
scope.add_event_processor(
300+
_make_client_processor(
301+
trace_config_ctx=trace_config_ctx,
302+
response=response,
303+
response_content=response_content,
304+
)
305+
)
306+
307+
if (
308+
response_content
309+
and isinstance(response_content, dict)
310+
and response_content.get("errors")
311+
):
312+
try:
313+
raise SentryGraphQLClientError
314+
except SentryGraphQLClientError as ex:
315+
event, hint = event_from_exception(
316+
ex,
317+
client_options=hub.client.options
318+
if hub.client
319+
else None,
320+
mechanism={
321+
"type": AioHttpIntegration.identifier,
322+
"handled": False,
323+
},
324+
)
325+
hub.capture_event(event, hint=hint)
326+
327+
if trace_config_ctx.span is not None:
328+
span.finish()
246329

247330
trace_config = TraceConfig()
248331

249332
trace_config.on_request_start.append(on_request_start)
333+
trace_config.on_request_chunk_sent.append(on_request_chunk_sent)
250334
trace_config.on_request_end.append(on_request_end)
251335

252336
return trace_config
253337

254338

255-
def _make_request_processor(weak_request):
339+
def _make_server_processor(weak_request):
256340
# type: (Callable[[], Request]) -> EventProcessor
257-
def aiohttp_processor(
341+
def aiohttp_server_processor(
258342
event, # type: Dict[str, Any]
259343
hint, # type: Dict[str, Tuple[type, BaseException, Any]]
260344
):
@@ -286,7 +370,63 @@ def aiohttp_processor(
286370

287371
return event
288372

289-
return aiohttp_processor
373+
return aiohttp_server_processor
374+
375+
376+
def _make_client_processor(trace_config_ctx, response, response_content):
377+
# type: (SimpleNamespace, Response, Optional[Dict[str, Any]]) -> EventProcessor
378+
def aiohttp_client_processor(
379+
event, # type: Dict[str, Any]
380+
hint, # type: Dict[str, Tuple[type, BaseException, Any]]
381+
):
382+
# type: (...) -> Dict[str, Any]
383+
with capture_internal_exceptions():
384+
request_info = event.setdefault("request", {})
385+
386+
parsed_url = parse_url(str(response.url), sanitize=False)
387+
request_info["url"] = parsed_url.url
388+
request_info["method"] = response.method
389+
390+
if getattr(trace_config_ctx, "request_headers", None):
391+
request_info["headers"] = _filter_headers(
392+
dict(trace_config_ctx.request_headers)
393+
)
394+
395+
if _should_send_default_pii():
396+
if getattr(trace_config_ctx, "request_body", None):
397+
request_info["data"] = trace_config_ctx.request_body
398+
399+
request_info["query_string"] = parsed_url.query
400+
401+
if response.url.path == "/graphql":
402+
request_info["api_target"] = "graphql"
403+
404+
query = request_info.get("data")
405+
if response.method == "GET":
406+
query = dict(parse_qsl(parsed_url.query))
407+
408+
if query:
409+
operation_name = _get_graphql_operation_name(query)
410+
operation_type = _get_graphql_operation_type(query)
411+
event["fingerprint"] = [
412+
operation_name,
413+
operation_type,
414+
response.status,
415+
]
416+
event["exception"]["values"][0][
417+
"value"
418+
] = "GraphQL request failed, name: {}, type: {}".format(
419+
operation_name, operation_type
420+
)
421+
422+
if _should_send_default_pii() and response_content:
423+
contexts = event.setdefault("contexts", {})
424+
response_context = contexts.setdefault("response", {})
425+
response_context["data"] = response_content
426+
427+
return event
428+
429+
return aiohttp_client_processor
290430

291431

292432
def _capture_exception(hub):

0 commit comments

Comments
 (0)