diff --git a/sentry_sdk/integrations/aiohttp.py b/sentry_sdk/integrations/aiohttp.py index d2d431aefd..4174171a9a 100644 --- a/sentry_sdk/integrations/aiohttp.py +++ b/sentry_sdk/integrations/aiohttp.py @@ -1,10 +1,16 @@ +import json import sys import weakref +try: + from urllib.parse import parse_qsl +except ImportError: + from urlparse import parse_qsl # type: ignore + from sentry_sdk.api import continue_trace from sentry_sdk._compat import reraise from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.hub import Hub +from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.sessions import auto_session_tracking @@ -29,14 +35,17 @@ CONTEXTVARS_ERROR_MESSAGE, SENSITIVE_DATA_SUBSTITUTE, AnnotatedValue, + SentryGraphQLClientError, + _get_graphql_operation_name, + _get_graphql_operation_type, ) try: import asyncio from aiohttp import __version__ as AIOHTTP_VERSION - from aiohttp import ClientSession, TraceConfig - from aiohttp.web import Application, HTTPException, UrlDispatcher + from aiohttp import ClientSession, ContentTypeError, TraceConfig + from aiohttp.web import Application, HTTPException, UrlDispatcher, Response except ImportError: raise DidNotEnable("AIOHTTP not installed") @@ -45,7 +54,11 @@ if TYPE_CHECKING: from aiohttp.web_request import Request from aiohttp.abc import AbstractMatchInfo - from aiohttp import TraceRequestStartParams, TraceRequestEndParams + from aiohttp import ( + TraceRequestStartParams, + TraceRequestEndParams, + TraceRequestChunkSentParams, + ) from types import SimpleNamespace from typing import Any from typing import Dict @@ -64,8 +77,8 @@ class AioHttpIntegration(Integration): identifier = "aiohttp" - def __init__(self, transaction_style="handler_name"): - # type: (str) -> None + def __init__(self, transaction_style="handler_name", capture_graphql_errors=True): + # type: (str, bool) -> None if transaction_style not in TRANSACTION_STYLE_VALUES: raise ValueError( "Invalid value for transaction_style: %s (must be in %s)" @@ -73,6 +86,8 @@ def __init__(self, transaction_style="handler_name"): ) self.transaction_style = transaction_style + self.capture_graphql_errors = capture_graphql_errors + @staticmethod def setup_once(): # type: () -> None @@ -111,7 +126,7 @@ async def sentry_app_handle(self, request, *args, **kwargs): # create a task to wrap each request. with hub.configure_scope() as scope: scope.clear_breadcrumbs() - scope.add_event_processor(_make_request_processor(weak_request)) + scope.add_event_processor(_make_server_processor(weak_request)) transaction = continue_trace( request.headers, @@ -139,6 +154,7 @@ async def sentry_app_handle(self, request, *args, **kwargs): reraise(*_capture_exception(hub)) transaction.set_http_status(response.status) + return response Application._handle = sentry_app_handle @@ -198,7 +214,8 @@ def create_trace_config(): async def on_request_start(session, trace_config_ctx, params): # type: (ClientSession, SimpleNamespace, TraceRequestStartParams) -> None hub = Hub.current - if hub.get_integration(AioHttpIntegration) is None: + integration = hub.get_integration(AioHttpIntegration) + if integration is None: return method = params.method.upper() @@ -233,28 +250,95 @@ async def on_request_start(session, trace_config_ctx, params): params.headers[key] = value trace_config_ctx.span = span + trace_config_ctx.is_graphql_request = params.url.path == "/graphql" + + if integration.capture_graphql_errors and trace_config_ctx.is_graphql_request: + trace_config_ctx.request_headers = params.headers + + async def on_request_chunk_sent(session, trace_config_ctx, params): + # type: (ClientSession, SimpleNamespace, TraceRequestChunkSentParams) -> None + integration = Hub.current.get_integration(AioHttpIntegration) + if integration is None: + return + + if integration.capture_graphql_errors and trace_config_ctx.is_graphql_request: + trace_config_ctx.request_body = None + with capture_internal_exceptions(): + try: + trace_config_ctx.request_body = json.loads(params.chunk) + except json.JSONDecodeError: + return async def on_request_end(session, trace_config_ctx, params): # type: (ClientSession, SimpleNamespace, TraceRequestEndParams) -> None - if trace_config_ctx.span is None: + hub = Hub.current + integration = hub.get_integration(AioHttpIntegration) + if integration is None: return - span = trace_config_ctx.span - span.set_http_status(int(params.response.status)) - span.set_data("reason", params.response.reason) - span.finish() + response = params.response + + if trace_config_ctx.span is not None: + span = trace_config_ctx.span + span.set_http_status(int(response.status)) + span.set_data("reason", response.reason) + + if ( + integration.capture_graphql_errors + and trace_config_ctx.is_graphql_request + and response.method in ("GET", "POST") + and response.status == 200 + ): + with hub.configure_scope() as scope: + with capture_internal_exceptions(): + try: + response_content = await response.json() + except ContentTypeError: + pass + else: + scope.add_event_processor( + _make_client_processor( + trace_config_ctx=trace_config_ctx, + response=response, + response_content=response_content, + ) + ) + + if ( + response_content + and isinstance(response_content, dict) + and response_content.get("errors") + ): + try: + raise SentryGraphQLClientError + except SentryGraphQLClientError as ex: + event, hint = event_from_exception( + ex, + client_options=hub.client.options + if hub.client + else None, + mechanism={ + "type": AioHttpIntegration.identifier, + "handled": False, + }, + ) + hub.capture_event(event, hint=hint) + + if trace_config_ctx.span is not None: + span.finish() trace_config = TraceConfig() trace_config.on_request_start.append(on_request_start) + trace_config.on_request_chunk_sent.append(on_request_chunk_sent) trace_config.on_request_end.append(on_request_end) return trace_config -def _make_request_processor(weak_request): +def _make_server_processor(weak_request): # type: (Callable[[], Request]) -> EventProcessor - def aiohttp_processor( + def aiohttp_server_processor( event, # type: Dict[str, Any] hint, # type: Dict[str, Tuple[type, BaseException, Any]] ): @@ -286,7 +370,63 @@ def aiohttp_processor( return event - return aiohttp_processor + return aiohttp_server_processor + + +def _make_client_processor(trace_config_ctx, response, response_content): + # type: (SimpleNamespace, Response, Optional[Dict[str, Any]]) -> EventProcessor + def aiohttp_client_processor( + event, # type: Dict[str, Any] + hint, # type: Dict[str, Tuple[type, BaseException, Any]] + ): + # type: (...) -> Dict[str, Any] + with capture_internal_exceptions(): + request_info = event.setdefault("request", {}) + + parsed_url = parse_url(str(response.url), sanitize=False) + request_info["url"] = parsed_url.url + request_info["method"] = response.method + + if getattr(trace_config_ctx, "request_headers", None): + request_info["headers"] = _filter_headers( + dict(trace_config_ctx.request_headers) + ) + + if _should_send_default_pii(): + if getattr(trace_config_ctx, "request_body", None): + request_info["data"] = trace_config_ctx.request_body + + request_info["query_string"] = parsed_url.query + + if response.url.path == "/graphql": + request_info["api_target"] = "graphql" + + query = request_info.get("data") + if response.method == "GET": + query = dict(parse_qsl(parsed_url.query)) + + if query: + operation_name = _get_graphql_operation_name(query) + operation_type = _get_graphql_operation_type(query) + event["fingerprint"] = [ + operation_name, + operation_type, + response.status, + ] + event["exception"]["values"][0][ + "value" + ] = "GraphQL request failed, name: {}, type: {}".format( + operation_name, operation_type + ) + + if _should_send_default_pii() and response_content: + contexts = event.setdefault("contexts", {}) + response_context = contexts.setdefault("response", {}) + response_context["data"] = response_content + + return event + + return aiohttp_client_processor def _capture_exception(hub): diff --git a/sentry_sdk/integrations/httpx.py b/sentry_sdk/integrations/httpx.py index 04db5047b4..0834d46d5f 100644 --- a/sentry_sdk/integrations/httpx.py +++ b/sentry_sdk/integrations/httpx.py @@ -1,19 +1,40 @@ -from sentry_sdk import Hub +import json + +try: + # py3 + from urllib.parse import parse_qsl +except ImportError: + # py2 + from urlparse import parse_qsl # type: ignore + +try: + # py3 + from json import JSONDecodeError +except ImportError: + # py2 doesn't throw a specialized json error, just Value/TypeErrors + JSONDecodeError = ValueError # type: ignore + from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.integrations import Integration, DidNotEnable from sentry_sdk.tracing import BAGGAGE_HEADER_NAME from sentry_sdk.tracing_utils import should_propagate_trace from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, + SentryGraphQLClientError, capture_internal_exceptions, + event_from_exception, logger, parse_url, + _get_graphql_operation_name, + _get_graphql_operation_type, ) - from sentry_sdk._types import TYPE_CHECKING +from sentry_sdk.integrations._wsgi_common import _filter_headers if TYPE_CHECKING: - from typing import Any + from typing import Any, Dict, Tuple + from sentry_sdk._types import EventProcessor try: @@ -27,6 +48,10 @@ class HttpxIntegration(Integration): identifier = "httpx" + def __init__(self, capture_graphql_errors=True): + # type: (bool) -> None + self.capture_graphql_errors = capture_graphql_errors + @staticmethod def setup_once(): # type: () -> None @@ -45,7 +70,8 @@ def _install_httpx_client(): def send(self, request, **kwargs): # type: (Client, Request, **Any) -> Response hub = Hub.current - if hub.get_integration(HttpxIntegration) is None: + integration = hub.get_integration(HttpxIntegration) + if integration is None: return real_send(self, request, **kwargs) parsed_url = None @@ -86,6 +112,9 @@ def send(self, request, **kwargs): span.set_http_status(rv.status_code) span.set_data("reason", rv.reason_phrase) + if integration.capture_graphql_errors: + _capture_graphql_errors(hub, request, rv) + return rv Client.send = send @@ -98,7 +127,8 @@ def _install_httpx_async_client(): async def send(self, request, **kwargs): # type: (AsyncClient, Request, **Any) -> Response hub = Hub.current - if hub.get_integration(HttpxIntegration) is None: + integration = hub.get_integration(HttpxIntegration) + if integration is None: return await real_send(self, request, **kwargs) parsed_url = None @@ -139,6 +169,95 @@ async def send(self, request, **kwargs): span.set_http_status(rv.status_code) span.set_data("reason", rv.reason_phrase) + if integration.capture_graphql_errors: + _capture_graphql_errors(hub, request, rv) + return rv AsyncClient.send = send + + +def _make_request_processor(request, response): + # type: (Request, Response) -> EventProcessor + def httpx_processor( + event, # type: Dict[str, Any] + hint, # type: Dict[str, Tuple[type, BaseException, Any]] + ): + # type: (...) -> Dict[str, Any] + with capture_internal_exceptions(): + request_info = event.setdefault("request", {}) + + parsed_url = parse_url(str(request.url), sanitize=False) + request_info["url"] = parsed_url.url + request_info["method"] = request.method + request_info["headers"] = _filter_headers(dict(request.headers)) + + if _should_send_default_pii(): + request_info["query_string"] = parsed_url.query + + request_content = request.read() + if request_content: + try: + request_info["data"] = json.loads(request_content) + except (JSONDecodeError, TypeError): + pass + + if response: + response_content = response.json() + contexts = event.setdefault("contexts", {}) + response_context = contexts.setdefault("response", {}) + response_context["data"] = response_content + + if request.url.path == "/graphql": + request_info["api_target"] = "graphql" + + query = request_info.get("data") + if request.method == "GET": + query = dict(parse_qsl(parsed_url.query)) + + if query: + operation_name = _get_graphql_operation_name(query) + operation_type = _get_graphql_operation_type(query) + event["fingerprint"] = [operation_name, operation_type, 200] + event["exception"]["values"][0][ + "value" + ] = "GraphQL request failed, name: {}, type: {}".format( + operation_name, operation_type + ) + + return event + + return httpx_processor + + +def _capture_graphql_errors(hub, request, response): + # type: (Hub, Request, Response) -> None + if ( + request.url.path == "/graphql" + and request.method in ("GET", "POST") + and response.status_code == 200 + ): + with hub.configure_scope() as scope: + scope.add_event_processor(_make_request_processor(request, response)) + + with capture_internal_exceptions(): + try: + response_content = response.json() + except JSONDecodeError: + return + + if isinstance(response_content, dict) and response_content.get( + "errors" + ): + try: + raise SentryGraphQLClientError + except SentryGraphQLClientError as ex: + event, hint = event_from_exception( + ex, + client_options=hub.client.options if hub.client else None, + mechanism={ + "type": HttpxIntegration.identifier, + "handled": False, + }, + ) + hub.capture_event(event, hint=hint) diff --git a/sentry_sdk/integrations/stdlib.py b/sentry_sdk/integrations/stdlib.py index be02779d88..43049a06a7 100644 --- a/sentry_sdk/integrations/stdlib.py +++ b/sentry_sdk/integrations/stdlib.py @@ -1,31 +1,51 @@ +import io +import json import os import subprocess import sys import platform -from sentry_sdk.consts import OP, SPANDATA -from sentry_sdk.hub import Hub +try: + # py3 + from urllib.parse import parse_qsl +except ImportError: + # py2 + from urlparse import parse_qsl # type: ignore + +try: + # py3 + from json import JSONDecodeError +except ImportError: + # py2 doesn't throw a specialized json error, just Value/TypeErrors + JSONDecodeError = ValueError # type: ignore + +from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.hub import Hub, _should_send_default_pii from sentry_sdk.integrations import Integration from sentry_sdk.scope import add_global_event_processor from sentry_sdk.tracing_utils import EnvironHeaders, should_propagate_trace from sentry_sdk.utils import ( SENSITIVE_DATA_SUBSTITUTE, + SentryGraphQLClientError, capture_internal_exceptions, + event_from_exception, logger, safe_repr, parse_url, + _get_graphql_operation_name, + _get_graphql_operation_type, ) - from sentry_sdk._types import TYPE_CHECKING if TYPE_CHECKING: from typing import Any from typing import Callable from typing import Dict - from typing import Optional from typing import List + from typing import Optional + from typing import Tuple - from sentry_sdk._types import Event, Hint + from sentry_sdk._types import Event, EventProcessor, Hint try: @@ -44,6 +64,10 @@ class StdlibIntegration(Integration): identifier = "stdlib" + def __init__(self, capture_graphql_errors=True): + # type: (bool) -> None + self.capture_graphql_errors = capture_graphql_errors + @staticmethod def setup_once(): # type: () -> None @@ -64,6 +88,7 @@ def add_python_runtime_context(event, hint): def _install_httplib(): # type: () -> None real_putrequest = HTTPConnection.putrequest + real_endheaders = HTTPConnection.endheaders real_getresponse = HTTPConnection.getresponse def putrequest(self, method, url, *args, **kwargs): @@ -84,10 +109,12 @@ def putrequest(self, method, url, *args, **kwargs): port != default_port and ":%s" % port or "", url, ) + self._sentrysdk_url = real_url parsed_url = None with capture_internal_exceptions(): parsed_url = parse_url(real_url, sanitize=False) + self._sentrysdk_is_graphql_request = parsed_url.url.endswith("/graphql") span = hub.start_span( op=OP.HTTP_CLIENT, @@ -113,28 +140,142 @@ def putrequest(self, method, url, *args, **kwargs): self.putheader(key, value) self._sentrysdk_span = span + self._sentrysdk_method = method + + return rv + + def endheaders(self, message_body=None, **kwargs): + # type: (HTTPConnection, Any, **Any) -> Any + rv = real_endheaders(self, message_body, **kwargs) + + integration = Hub.current.get_integration(StdlibIntegration) + if integration is None: + return rv + + if integration.capture_graphql_errors and getattr( + self, "_sentrysdk_is_graphql_request", False + ): + self._sentry_request_body = message_body return rv def getresponse(self, *args, **kwargs): # type: (HTTPConnection, *Any, **Any) -> Any - span = getattr(self, "_sentrysdk_span", None) + rv = real_getresponse(self, *args, **kwargs) + + hub = Hub.current + integration = hub.get_integration(StdlibIntegration) + if integration is None: + return rv - if span is None: - return real_getresponse(self, *args, **kwargs) + span = getattr(self, "_sentrysdk_span", None) + if span is not None: + span.set_http_status(int(rv.status)) + span.set_data("reason", rv.reason) + span.finish() - rv = real_getresponse(self, *args, **kwargs) + url = getattr(self, "_sentrysdk_url", None) # type: Optional[str] + if url is None: + return rv - span.set_http_status(int(rv.status)) - span.set_data("reason", rv.reason) - span.finish() + if integration.capture_graphql_errors: + response_body = None + if getattr(self, "_sentrysdk_is_graphql_request", False): + with capture_internal_exceptions(): + response_data = rv.read() + # once we've read() the body it can't be read() again by the + # app; save it so that it can be accessed again + rv.read = io.BytesIO(response_data).read + try: + # py3.6+ json.loads() can deal with bytes out of the box, but + # for older version we have to explicitly decode first + response_body = json.loads(response_data.decode()) + except (JSONDecodeError, UnicodeDecodeError, TypeError): + return rv + + is_graphql_response_with_errors = isinstance( + response_body, dict + ) and response_body.get("errors") + if is_graphql_response_with_errors: + method = getattr(self, "_sentrysdk_method", None) # type: Optional[str] + request_body = getattr(self, "_sentry_request_body", None) + with hub.configure_scope() as scope: + scope.add_event_processor( + _make_request_processor( + url, method, rv.status, request_body, response_body + ) + ) + try: + raise SentryGraphQLClientError + except SentryGraphQLClientError as ex: + event, hint = event_from_exception( + ex, + client_options=hub.client.options if hub.client else None, + mechanism={ + "type": StdlibIntegration.identifier, + "handled": False, + }, + ) + + hub.capture_event(event, hint=hint) return rv HTTPConnection.putrequest = putrequest + HTTPConnection.endheaders = endheaders HTTPConnection.getresponse = getresponse +def _make_request_processor(url, method, status, request_body, response_body): + # type: (str, Optional[str], int, Any, Any) -> EventProcessor + def stdlib_processor( + event, # type: Dict[str, Any] + hint, # type: Dict[str, Tuple[type, BaseException, Any]] + ): + # type: (...) -> Optional[Event] + with capture_internal_exceptions(): + request_info = event.setdefault("request", {}) + + parsed_url = parse_url(url, sanitize=False) + + if _should_send_default_pii(): + request_info["query_string"] = parsed_url.query + + request_info["url"] = parsed_url.url + request_info["method"] = method + + if _should_send_default_pii(): + try: + request_info["data"] = json.loads(request_body.decode()) + except (JSONDecodeError, AttributeError): + pass + + if response_body: + contexts = event.setdefault("contexts", {}) + response_context = contexts.setdefault("response", {}) + response_context["data"] = response_body + + if parsed_url.url.endswith("/graphql"): + request_info["api_target"] = "graphql" + query = request_info.get("data") + if method == "GET": + query = dict(parse_qsl(parsed_url.query)) + + if query: + operation_name = _get_graphql_operation_name(query) + operation_type = _get_graphql_operation_type(query) + event["fingerprint"] = [operation_name, operation_type, status] + event["exception"]["values"][0][ + "value" + ] = "GraphQL request failed, name: {}, type: {}".format( + operation_name, operation_type + ) + + return event + + return stdlib_processor + + def _init_argument(args, kwargs, name, position, setdefault_callback=None): # type: (List[Any], Dict[Any, Any], str, int, Optional[Callable[[Any], Any]]) -> Any """ diff --git a/sentry_sdk/scrubber.py b/sentry_sdk/scrubber.py index 838ef08b4b..8c828fe444 100644 --- a/sentry_sdk/scrubber.py +++ b/sentry_sdk/scrubber.py @@ -84,6 +84,16 @@ def scrub_request(self, event): if "data" in event["request"]: self.scrub_dict(event["request"]["data"]) + def scrub_response(self, event): + # type: (Event) -> None + with capture_internal_exceptions(): + if ( + "contexts" in event + and "response" in event["contexts"] + and "data" in event["contexts"]["response"] + ): + self.scrub_dict(event["contexts"]["response"]["data"]) + def scrub_extra(self, event): # type: (Event) -> None with capture_internal_exceptions(): @@ -123,6 +133,7 @@ def scrub_spans(self, event): def scrub_event(self, event): # type: (Event) -> None self.scrub_request(event) + self.scrub_response(event) self.scrub_extra(event) self.scrub_user(event) self.scrub_breadcrumbs(event) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 475652c7bd..80076f9a61 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -1287,6 +1287,39 @@ class ServerlessTimeoutWarning(Exception): # noqa: N818 pass +class SentryGraphQLClientError(Exception): + """Synthetic exception for GraphQL client errors.""" + + pass + + +def _get_graphql_operation_name(query): + # type: (Dict[str, Any]) -> str + if query.get("operationName"): + return query["operationName"] + + query = query["query"].strip() + + match = re.match( + r"((query|mutation|subscription) )(?P[a-zA-Z0-9]+).*\{", + query, + flags=re.IGNORECASE, + ) + if match: + return match.group("name") + return "anonymous" + + +def _get_graphql_operation_type(query): + # type: (Dict[str, Any]) -> str + query = query["query"].strip().lower() + if query.startswith("mutation"): + return "mutation" + if query.startswith("subscription"): + return "subscription" + return "query" + + class TimeoutThread(threading.Thread): """Creates a Thread which runs (sleeps) for a time duration equal to waiting_time and raises a custom ServerlessTimeout exception. diff --git a/tests/conftest.py b/tests/conftest.py index d9d88067dc..cb61bbbdbf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -584,6 +584,12 @@ def do_GET(self): # noqa: N802 self.end_headers() return + def do_POST(self): # noqa: N802 + # Process an HTTP POST request and return a response with an HTTP 200 status. + self.send_response(200) + self.end_headers() + return + def get_free_port(): s = socket.socket(socket.AF_INET, type=socket.SOCK_STREAM) diff --git a/tests/integrations/aiohttp/test_aiohttp.py b/tests/integrations/aiohttp/test_aiohttp.py index 8068365334..79ed402554 100644 --- a/tests/integrations/aiohttp/test_aiohttp.py +++ b/tests/integrations/aiohttp/test_aiohttp.py @@ -1,20 +1,46 @@ import asyncio import json from contextlib import suppress +from textwrap import dedent import pytest from aiohttp import web from aiohttp.client import ServerDisconnectedError -from aiohttp.web_request import Request +from aiohttp.web import Request, Response, json_response from sentry_sdk import capture_message, start_transaction from sentry_sdk.integrations.aiohttp import AioHttpIntegration +from sentry_sdk.utils import parse_version try: from unittest import mock # python 3.3 and above except ImportError: import mock # python < 3.3 +try: + from importlib.metadata import version # py 3.8+ + + AIOHTTP_VERSION = tuple(parse_version(version("aiohttp"))[:2]) + +except ImportError: + from pkg_resources import get_distribution + + AIOHTTP_VERSION = tuple(parse_version(get_distribution("aiohttp").version)[:2]) + + +def min_aiohttp_version(major, minor, reason=None): + if reason is None: + reason = "Requires aiohttp {}.{} or higher".format(major, minor) + + return pytest.mark.skipif(AIOHTTP_VERSION < (major, minor), reason=reason) + + +def max_aiohttp_version(major, minor, reason=None): + if reason is None: + reason = "Requires aiohttp {}.{} or lower".format(major, minor) + + return pytest.mark.skipif(AIOHTTP_VERSION > (major, minor), reason=reason) + @pytest.mark.asyncio async def test_basic(sentry_init, aiohttp_client, capture_events): @@ -534,3 +560,306 @@ async def handler(request): resp.request_info.headers["baggage"] == "custom=value,sentry-trace_id=0123456789012345678901234567890,sentry-environment=production,sentry-release=d08ebdb9309e1b004c6f52202de58a09c2268e42,sentry-transaction=/interactions/other-dogs/new-dog,sentry-sample_rate=1.0,sentry-sampled=true" ) + + +@pytest.mark.asyncio +async def test_graphql_get_client_error_captured( + sentry_init, capture_events, aiohttp_raw_server, aiohttp_client +): + sentry_init(send_default_pii=True, integrations=[AioHttpIntegration()]) + + graphql_response = { + "data": None, + "errors": [ + { + "message": "some error", + "locations": [{"line": 2, "column": 3}], + "path": ["pet"], + } + ], + } + + async def handler(request): + return json_response(graphql_response) + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.get( + "/graphql", params={"query": "query GetPet {pet{name}}"} + ) + + assert response.status == 200 + assert await response.json() == graphql_response + + (event,) = events + + assert event["request"]["url"] == "http://127.0.0.1:{}/graphql".format( + raw_server.port + ) + assert event["request"]["method"] == "GET" + assert event["request"]["query_string"] == "query=query+GetPet+%7Bpet%7Bname%7D%7D" + assert "data" not in event["request"] + assert event["contexts"]["response"]["data"] == graphql_response + + assert event["request"]["api_target"] == "graphql" + assert event["fingerprint"] == ["GetPet", "query", 200] + assert ( + event["exception"]["values"][0]["value"] + == "GraphQL request failed, name: GetPet, type: query" + ) + + +@pytest.mark.asyncio +async def test_graphql_post_client_error_captured( + sentry_init, capture_events, aiohttp_client, aiohttp_raw_server +): + sentry_init(send_default_pii=True, integrations=[AioHttpIntegration()]) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + "errors": [ + { + "message": "already have too many pets", + "locations": [{"line": 1, "column": 1}], + } + ], + } + + async def handler(request): + return json_response(graphql_response) + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.post("/graphql", json=graphql_request) + + assert response.status == 200 + assert await response.json() == graphql_response + + (event,) = events + + assert event["request"]["url"] == "http://127.0.0.1:{}/graphql".format( + raw_server.port + ) + assert event["request"]["method"] == "POST" + assert event["request"]["query_string"] == "" + assert event["request"]["data"] == graphql_request + assert event["contexts"]["response"]["data"] == graphql_response + + assert event["request"]["api_target"] == "graphql" + assert event["fingerprint"] == ["AddPet", "mutation", 200] + assert ( + event["exception"]["values"][0]["value"] + == "GraphQL request failed, name: AddPet, type: mutation" + ) + + +@pytest.mark.asyncio +async def test_graphql_get_client_no_errors_returned( + sentry_init, capture_events, aiohttp_raw_server, aiohttp_client +): + sentry_init(send_default_pii=True, integrations=[AioHttpIntegration()]) + + graphql_response = { + "data": None, + } + + async def handler(request): + return json_response(graphql_response) + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.get( + "/graphql", params={"query": "query GetPet {pet{name}}"} + ) + + assert response.status == 200 + assert await response.json() == graphql_response + + assert not events + + +@pytest.mark.asyncio +async def test_graphql_post_client_no_errors_returned( + sentry_init, capture_events, aiohttp_client, aiohttp_raw_server +): + sentry_init(send_default_pii=True, integrations=[AioHttpIntegration()]) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + } + + async def handler(request): + return json_response(graphql_response) + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.post("/graphql", json=graphql_request) + + assert response.status == 200 + assert await response.json() == graphql_response + + assert not events + + +@pytest.mark.asyncio +async def test_graphql_no_get_errors_if_option_is_off( + sentry_init, capture_events, aiohttp_raw_server, aiohttp_client +): + sentry_init( + send_default_pii=True, + integrations=[AioHttpIntegration(capture_graphql_errors=False)], + ) + + graphql_response = { + "data": None, + "errors": [ + { + "message": "some error", + "locations": [{"line": 2, "column": 3}], + "path": ["pet"], + } + ], + } + + async def handler(request): + return json_response(graphql_response) + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.get( + "/graphql", params={"query": "query GetPet {pet{name}}"} + ) + + assert response.status == 200 + assert await response.json() == graphql_response + + assert not events + + +@pytest.mark.asyncio +async def test_graphql_no_post_errors_if_option_is_off( + sentry_init, capture_events, aiohttp_client, aiohttp_raw_server +): + sentry_init( + send_default_pii=True, + integrations=[AioHttpIntegration(capture_graphql_errors=False)], + ) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + "errors": [ + { + "message": "already have too many pets", + "locations": [{"line": 1, "column": 1}], + } + ], + } + + async def handler(request): + return json_response(graphql_response) + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.post("/graphql", json=graphql_request) + + assert response.status == 200 + assert await response.json() == graphql_response + + assert not events + + +@pytest.mark.asyncio +async def test_graphql_non_json_response( + sentry_init, capture_events, aiohttp_client, aiohttp_raw_server +): + sentry_init( + send_default_pii=True, + integrations=[AioHttpIntegration()], + ) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + + async def handler(request): + return Response(body=b"not json") + + raw_server = await aiohttp_raw_server(handler) + events = capture_events() + + client = await aiohttp_client(raw_server) + response = await client.post("/graphql", json=graphql_request) + + assert response.status == 200 + assert await response.text() == "not json" + + assert not events diff --git a/tests/integrations/httpx/test_httpx.py b/tests/integrations/httpx/test_httpx.py index e141faa282..8bae3ee3c4 100644 --- a/tests/integrations/httpx/test_httpx.py +++ b/tests/integrations/httpx/test_httpx.py @@ -2,7 +2,7 @@ import pytest import httpx -import responses +from textwrap import dedent from sentry_sdk import capture_message, start_transaction from sentry_sdk.consts import MATCH_ALL, SPANDATA @@ -13,12 +13,17 @@ except ImportError: import mock # python < 3.3 +try: + from urllib.parse import parse_qsl +except ImportError: + from urlparse import parse_qsl # type: ignore + @pytest.mark.parametrize( "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_crumb_capture_and_hint(sentry_init, capture_events, httpx_client): +def test_crumb_capture_and_hint(sentry_init, capture_events, httpx_client, httpx_mock): def before_breadcrumb(crumb, hint): crumb["data"]["extra"] = "foo" return crumb @@ -26,7 +31,7 @@ def before_breadcrumb(crumb, hint): sentry_init(integrations=[HttpxIntegration()], before_breadcrumb=before_breadcrumb) url = "http://example.com/" - responses.add(responses.GET, url, status=200) + httpx_mock.add_response() with start_transaction(): events = capture_events() @@ -61,11 +66,11 @@ def before_breadcrumb(crumb, hint): "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_outgoing_trace_headers(sentry_init, httpx_client): +def test_outgoing_trace_headers(sentry_init, httpx_client, httpx_mock): sentry_init(traces_sample_rate=1.0, integrations=[HttpxIntegration()]) url = "http://example.com/" - responses.add(responses.GET, url, status=200) + httpx_mock.add_response() with start_transaction( name="/interactions/other-dogs/new-dog", @@ -93,7 +98,9 @@ def test_outgoing_trace_headers(sentry_init, httpx_client): "httpx_client", (httpx.Client(), httpx.AsyncClient()), ) -def test_outgoing_trace_headers_append_to_baggage(sentry_init, httpx_client): +def test_outgoing_trace_headers_append_to_baggage( + sentry_init, httpx_client, httpx_mock +): sentry_init( traces_sample_rate=1.0, integrations=[HttpxIntegration()], @@ -101,7 +108,7 @@ def test_outgoing_trace_headers_append_to_baggage(sentry_init, httpx_client): ) url = "http://example.com/" - responses.add(responses.GET, url, status=200) + httpx_mock.add_response() with start_transaction( name="/interactions/other-dogs/new-dog", @@ -273,12 +280,12 @@ def test_option_trace_propagation_targets( @pytest.mark.tests_internal_exceptions -def test_omit_url_data_if_parsing_fails(sentry_init, capture_events): +def test_omit_url_data_if_parsing_fails(sentry_init, capture_events, httpx_mock): sentry_init(integrations=[HttpxIntegration()]) httpx_client = httpx.Client() url = "http://example.com" - responses.add(responses.GET, url, status=200) + httpx_mock.add_response() events = capture_events() with mock.patch( @@ -297,3 +304,336 @@ def test_omit_url_data_if_parsing_fails(sentry_init, capture_events): "reason": "OK", # no url related data } + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_get_client_error_captured( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init(send_default_pii=True, integrations=[HttpxIntegration()]) + + url = "http://example.com/graphql" + graphql_response = { + "data": None, + "errors": [ + { + "message": "some error", + "locations": [{"line": 2, "column": 3}], + "path": ["user"], + } + ], + } + params = {"query": "query QueryName {user{name}}"} + + httpx_mock.add_response(method="GET", json=graphql_response) + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url, params=params) + ) + else: + response = httpx_client.get(url, params=params) + + assert response.status_code == 200 + assert response.json() == graphql_response + + (event,) = events + + assert event["request"]["url"] == url + assert event["request"]["method"] == "GET" + assert dict(parse_qsl(event["request"]["query_string"])) == params + assert "data" not in event["request"] + assert event["contexts"]["response"]["data"] == graphql_response + + assert event["request"]["api_target"] == "graphql" + assert event["fingerprint"] == ["QueryName", "query", 200] + assert ( + event["exception"]["values"][0]["value"] + == "GraphQL request failed, name: QueryName, type: query" + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_post_client_error_captured( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init(send_default_pii=True, integrations=[HttpxIntegration()]) + + url = "http://example.com/graphql" + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + "errors": [ + { + "message": "already have too many pets", + "locations": [{"line": 1, "column": 1}], + } + ], + } + httpx_mock.add_response(method="POST", json=graphql_response) + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.post): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.post(url, json=graphql_request) + ) + else: + response = httpx_client.post(url, json=graphql_request) + + assert response.status_code == 200 + assert response.json() == graphql_response + + (event,) = events + + assert event["request"]["url"] == url + assert event["request"]["method"] == "POST" + assert event["request"]["query_string"] == "" + assert event["request"]["data"] == graphql_request + assert event["contexts"]["response"]["data"] == graphql_response + + assert event["request"]["api_target"] == "graphql" + assert event["fingerprint"] == ["AddPet", "mutation", 200] + assert ( + event["exception"]["values"][0]["value"] + == "GraphQL request failed, name: AddPet, type: mutation" + ) + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_get_client_no_errors_returned( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init(send_default_pii=True, integrations=[HttpxIntegration()]) + + url = "http://example.com/graphql" + graphql_response = { + "data": None, + } + params = {"query": "query QueryName {user{name}}"} + + httpx_mock.add_response(method="GET", json=graphql_response) + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url, params=params) + ) + else: + response = httpx_client.get(url, params=params) + + assert response.status_code == 200 + assert response.json() == graphql_response + + assert not events + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_post_client_no_errors_returned( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init(send_default_pii=True, integrations=[HttpxIntegration()]) + + url = "http://example.com/graphql" + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + } + httpx_mock.add_response(method="POST", json=graphql_response) + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.post): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.post(url, json=graphql_request) + ) + else: + response = httpx_client.post(url, json=graphql_request) + + assert response.status_code == 200 + assert response.json() == graphql_response + + assert not events + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_no_get_errors_if_option_is_off( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init( + send_default_pii=True, + integrations=[HttpxIntegration(capture_graphql_errors=False)], + ) + + url = "http://example.com/graphql" + graphql_response = { + "data": None, + "errors": [ + { + "message": "some error", + "locations": [{"line": 2, "column": 3}], + "path": ["user"], + } + ], + } + params = {"query": "query QueryName {user{name}}"} + + httpx_mock.add_response(method="GET", json=graphql_response) + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.get): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.get(url, params=params) + ) + else: + response = httpx_client.get(url, params=params) + + assert response.status_code == 200 + assert response.json() == graphql_response + + assert not events + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_no_post_errors_if_option_is_off( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init( + send_default_pii=True, + integrations=[HttpxIntegration(capture_graphql_errors=False)], + ) + + url = "http://example.com/graphql" + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + "errors": [ + { + "message": "already have too many pets", + "locations": [{"line": 1, "column": 1}], + } + ], + } + httpx_mock.add_response(method="POST", json=graphql_response) + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.post): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.post(url, json=graphql_request) + ) + else: + response = httpx_client.post(url, json=graphql_request) + + assert response.status_code == 200 + assert response.json() == graphql_response + + assert not events + + +@pytest.mark.parametrize( + "httpx_client", + (httpx.Client(), httpx.AsyncClient()), +) +def test_graphql_non_json_response( + sentry_init, capture_events, httpx_client, httpx_mock +): + sentry_init( + send_default_pii=True, + integrations=[HttpxIntegration()], + ) + + url = "http://example.com/graphql" + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + httpx_mock.add_response(method="POST") + + events = capture_events() + + if asyncio.iscoroutinefunction(httpx_client.post): + response = asyncio.get_event_loop().run_until_complete( + httpx_client.post(url, json=graphql_request) + ) + else: + response = httpx_client.post(url, json=graphql_request) + + assert response.status_code == 200 + + assert not events diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index e40f5222d7..39efe3d22f 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -1,4 +1,6 @@ +import json import random +from textwrap import dedent import pytest @@ -16,6 +18,14 @@ # py3 from http.client import HTTPConnection, HTTPSConnection +try: + # py3 + from urllib.parse import parse_qsl, urlencode +except ImportError: + # py2 + from urlparse import parse_qsl # type: ignore + from urllib import urlencode # type: ignore + try: from unittest import mock # python 3.3 and above except ImportError: @@ -27,7 +37,7 @@ from sentry_sdk.tracing import Transaction from sentry_sdk.integrations.stdlib import StdlibIntegration -from tests.conftest import create_mock_http_server +from tests.conftest import MockServerRequestHandler, create_mock_http_server PORT = create_mock_http_server() @@ -341,3 +351,299 @@ def test_option_trace_propagation_targets( else: assert "sentry-trace" not in request_headers assert "baggage" not in request_headers + + +def test_graphql_get_client_error_captured(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[StdlibIntegration()]) + + params = {"query": "query QueryName {user{name}}"} + graphql_response = { + "data": None, + "errors": [ + { + "message": "some error", + "locations": [{"line": 2, "column": 3}], + "path": ["user"], + } + ], + } + + events = capture_events() + + def do_GET(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(graphql_response).encode()) + + with mock.patch.object(MockServerRequestHandler, "do_GET", do_GET): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("GET", "/graphql?" + urlencode(params)) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == json.dumps(graphql_response).encode() + + (event,) = events + + assert event["request"]["url"] == "http://localhost:{}/graphql".format(PORT) + assert event["request"]["method"] == "GET" + assert dict(parse_qsl(event["request"]["query_string"])) == params + assert "data" not in event["request"] + assert event["contexts"]["response"]["data"] == graphql_response + + assert event["request"]["api_target"] == "graphql" + assert event["fingerprint"] == ["QueryName", "query", 200] + assert ( + event["exception"]["values"][0]["value"] + == "GraphQL request failed, name: QueryName, type: query" + ) + + +def test_graphql_post_client_error_captured(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[StdlibIntegration()]) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + "errors": [ + { + "message": "already have too many pets", + "locations": [{"line": 1, "column": 1}], + } + ], + } + + events = capture_events() + + def do_POST(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(graphql_response).encode()) + + with mock.patch.object(MockServerRequestHandler, "do_POST", do_POST): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("POST", "/graphql", body=json.dumps(graphql_request).encode()) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == json.dumps(graphql_response).encode() + + (event,) = events + + assert event["request"]["url"] == "http://localhost:{}/graphql".format(PORT) + assert event["request"]["method"] == "POST" + assert event["request"]["query_string"] == "" + assert event["request"]["data"] == graphql_request + assert event["contexts"]["response"]["data"] == graphql_response + + assert event["request"]["api_target"] == "graphql" + assert event["fingerprint"] == ["AddPet", "mutation", 200] + assert ( + event["exception"]["values"][0]["value"] + == "GraphQL request failed, name: AddPet, type: mutation" + ) + + +def test_graphql_get_client_no_errors_returned(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[StdlibIntegration()]) + + params = {"query": "query QueryName {user{name}}"} + graphql_response = { + "data": None, + } + + events = capture_events() + + def do_GET(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(graphql_response).encode()) + + with mock.patch.object(MockServerRequestHandler, "do_GET", do_GET): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("GET", "/graphql?" + urlencode(params)) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == json.dumps(graphql_response).encode() + + assert not events + + +def test_graphql_post_client_no_errors_returned(sentry_init, capture_events): + sentry_init(send_default_pii=True, integrations=[StdlibIntegration()]) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + } + + events = capture_events() + + def do_POST(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(graphql_response).encode()) + + with mock.patch.object(MockServerRequestHandler, "do_POST", do_POST): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("POST", "/graphql", body=json.dumps(graphql_request).encode()) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == json.dumps(graphql_response).encode() + + assert not events + + +def test_graphql_no_get_errors_if_option_is_off(sentry_init, capture_events): + sentry_init( + send_default_pii=True, + integrations=[StdlibIntegration(capture_graphql_errors=False)], + ) + + params = {"query": "query QueryName {user{name}}"} + graphql_response = { + "data": None, + "errors": [ + { + "message": "some error", + "locations": [{"line": 2, "column": 3}], + "path": ["user"], + } + ], + } + + events = capture_events() + + def do_GET(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(graphql_response).encode()) + + with mock.patch.object(MockServerRequestHandler, "do_GET", do_GET): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("GET", "/graphql?" + urlencode(params)) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == json.dumps(graphql_response).encode() + + assert not events + + +def test_graphql_no_post_errors_if_option_is_off(sentry_init, capture_events): + sentry_init( + send_default_pii=True, + integrations=[StdlibIntegration(capture_graphql_errors=False)], + ) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + graphql_response = { + "data": None, + "errors": [ + { + "message": "already have too many pets", + "locations": [{"line": 1, "column": 1}], + } + ], + } + + events = capture_events() + + def do_POST(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(graphql_response).encode()) + + with mock.patch.object(MockServerRequestHandler, "do_POST", do_POST): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("POST", "/graphql", body=json.dumps(graphql_request).encode()) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == json.dumps(graphql_response).encode() + + assert not events + + +def test_graphql_non_json_response(sentry_init, capture_events): + sentry_init( + send_default_pii=True, + integrations=[StdlibIntegration()], + ) + + graphql_request = { + "query": dedent( + """ + mutation AddPet ($name: String!) { + addPet(name: $name) { + id + name + } + } + """ + ), + "variables": { + "name": "Lucy", + }, + } + + events = capture_events() + + def do_POST(self): # noqa: N802 + self.send_response(200) + self.end_headers() + self.wfile.write(b"not json") + + with mock.patch.object(MockServerRequestHandler, "do_POST", do_POST): + conn = HTTPConnection("localhost:{}".format(PORT)) + conn.request("POST", "/graphql", body=json.dumps(graphql_request).encode()) + response = conn.getresponse() + + # make sure the response can still be read() normally + assert response.read() == b"not json" + + assert not events diff --git a/tests/test_utils.py b/tests/test_utils.py index 47460d39b0..3a5a4bd384 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -11,6 +11,8 @@ parse_version, sanitize_url, serialize_frame, + _get_graphql_operation_name, + _get_graphql_operation_type, ) try: @@ -423,3 +425,103 @@ def test_match_regex_list(item, regex_list, expected_result): ) def test_parse_version(version, expected_result): assert parse_version(version) == expected_result + + +@pytest.mark.parametrize( + "query,expected_result", + [ + [{"query": '{cats(id: "7") {name}}'}, "anonymous"], + [{"query": 'query {cats(id: "7") {name}}'}, "anonymous"], + [{"query": 'query CatQuery {cats(id: "7") {name}}'}, "CatQuery"], + [ + { + "query": 'mutation {addCategory(id: 6, name: "Lily", cats: [8, 2]) {name cats {name}}}' + }, + "anonymous", + ], + [ + { + "query": 'mutation categoryAdd {addCategory(id: 6, name: "Lily", cats: [8, 2]) {name cats {name}}}' + }, + "categoryAdd", + ], + [ + { + "query": "subscription {newLink {id url description postedBy {id name email}}}" + }, + "anonymous", + ], + [ + { + "query": "subscription PostSubcription {newLink {id url description postedBy {id name email}}}" + }, + "PostSubcription", + ], + [ + { + "query": 'query CatQuery {cats(id: "7") {name}}', + "operationName": "SomeOtherOperation", + "variables": {}, + }, + "SomeOtherOperation", + ], + [ + { + "query": "mutation AddPet ($name: String!) {addPet(name: $name) {id name}}}" + }, + "AddPet", + ], + ], +) +def test_graphql_operation_name_extraction(query, expected_result): + assert _get_graphql_operation_name(query) == expected_result + + +@pytest.mark.parametrize( + "query,expected_result", + [ + [{"query": '{cats(id: "7") {name}}'}, "query"], + [{"query": 'query {cats(id: "7") {name}}'}, "query"], + [{"query": 'query CatQuery {cats(id: "7") {name}}'}, "query"], + [ + { + "query": 'mutation {addCategory(id: 6, name: "Lily", cats: [8, 2]) {name cats {name}}}' + }, + "mutation", + ], + [ + { + "query": 'mutation categoryAdd {addCategory(id: 6, name: "Lily", cats: [8, 2]) {name cats {name}}}' + }, + "mutation", + ], + [ + { + "query": "subscription {newLink {id url description postedBy {id name email}}}" + }, + "subscription", + ], + [ + { + "query": "subscription PostSubcription {newLink {id url description postedBy {id name email}}}" + }, + "subscription", + ], + [ + { + "query": 'query CatQuery {cats(id: "7") {name}}', + "operationName": "SomeOtherOperation", + "variables": {}, + }, + "query", + ], + [ + { + "query": "mutation AddPet ($name: String!) {addPet(name: $name) {id name}}}" + }, + "mutation", + ], + ], +) +def test_graphql_operation_type_extraction(query, expected_result): + assert _get_graphql_operation_type(query) == expected_result