Repository: getsentry/sentry-python

Issue:
GraphQL client errors
### Problem Statement

Add support for customized errors for some of the most popular [GraphQL clients](https://graphql.org/code/#python).

Context:
* https://github.com/getsentry/sentry-python/issues/2198 (parent issue)
* https://github.com/getsentry/sentry-python/issues/2257 (server side)

### Solution Brainstorm

See how we can hook into the different GraphQL client libraries to capture errors and spans.

* expected behavior: https://develop.sentry.dev/sdk/features/#graphql-client-integrations
* RFC: https://github.com/getsentry/rfcs/blob/main/text/0087-graphql-errors.md
* previous (buggy) implementation via HTTP clients: https://github.com/getsentry/sentry-python/pull/2243/files

We might want to add an optional flag called `capture_errors` (default `True`) to all client integrations to allow folks to turn error reporting off. If a user has both their client and server side GraphQL instrumented with Sentry, they could use this to select which of the integrations they want to use to capture the error (which would otherwise be reported by both client and server).

Client GraphQL integrations need to be enabled explicitly, i.e., they shouldn't be auto-enabled. The feature should be opt in so that users are not hit with a lot of new unexpected errors that count towards their quota.


Discussion:

Resolving patch:
diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py
new file mode 100644
--- /dev/null
+++ b/sentry_sdk/integrations/gql.py
@@ -0,0 +1,142 @@
+from sentry_sdk.utils import event_from_exception, parse_version
+from sentry_sdk.hub import Hub, _should_send_default_pii
+from sentry_sdk.integrations import DidNotEnable, Integration
+
+try:
+    import gql  # type: ignore[import]
+    from graphql import print_ast, get_operation_ast, DocumentNode, VariableDefinitionNode  # type: ignore[import]
+    from gql.transport import Transport, AsyncTransport  # type: ignore[import]
+    from gql.transport.exceptions import TransportQueryError  # type: ignore[import]
+except ImportError:
+    raise DidNotEnable("gql is not installed")
+
+from sentry_sdk._types import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from typing import Any, Dict, Tuple, Union
+    from sentry_sdk._types import EventProcessor
+
+    EventDataType = Dict[str, Union[str, Tuple[VariableDefinitionNode, ...]]]
+
+MIN_GQL_VERSION = (3, 4, 1)
+
+
+class GQLIntegration(Integration):
+    identifier = "gql"
+
+    @staticmethod
+    def setup_once():
+        # type: () -> None
+        gql_version = parse_version(gql.__version__)
+        if gql_version is None or gql_version < MIN_GQL_VERSION:
+            raise DidNotEnable(
+                "GQLIntegration is only supported for GQL versions %s and above."
+                % ".".join(str(num) for num in MIN_GQL_VERSION)
+            )
+        _patch_execute()
+
+
+def _data_from_document(document):
+    # type: (DocumentNode) -> EventDataType
+    try:
+        operation_ast = get_operation_ast(document)
+        data = {"query": print_ast(document)}  # type: EventDataType
+
+        if operation_ast is not None:
+            data["variables"] = operation_ast.variable_definitions
+            if operation_ast.name is not None:
+                data["operationName"] = operation_ast.name.value
+
+        return data
+    except (AttributeError, TypeError):
+        return dict()
+
+
+def _transport_method(transport):
+    # type: (Union[Transport, AsyncTransport]) -> str
+    """
+    The RequestsHTTPTransport allows defining the HTTP method; all
+    other transports use POST.
+    """
+    try:
+        return transport.method
+    except AttributeError:
+        return "POST"
+
+
+def _request_info_from_transport(transport):
+    # type: (Union[Transport, AsyncTransport, None]) -> Dict[str, str]
+    if transport is None:
+        return {}
+
+    request_info = {
+        "method": _transport_method(transport),
+    }
+
+    try:
+        request_info["url"] = transport.url
+    except AttributeError:
+        pass
+
+    return request_info
+
+
+def _patch_execute():
+    # type: () -> None
+    real_execute = gql.Client.execute
+
+    def sentry_patched_execute(self, document, *args, **kwargs):
+        # type: (gql.Client, DocumentNode, Any, Any) -> Any
+        hub = Hub.current
+        if hub.get_integration(GQLIntegration) is None:
+            return real_execute(self, document, *args, **kwargs)
+
+        with Hub.current.configure_scope() as scope:
+            scope.add_event_processor(_make_gql_event_processor(self, document))
+
+        try:
+            return real_execute(self, document, *args, **kwargs)
+        except TransportQueryError as e:
+            event, hint = event_from_exception(
+                e,
+                client_options=hub.client.options if hub.client is not None else None,
+                mechanism={"type": "gql", "handled": False},
+            )
+
+            hub.capture_event(event, hint)
+            raise e
+
+    gql.Client.execute = sentry_patched_execute
+
+
+def _make_gql_event_processor(client, document):
+    # type: (gql.Client, DocumentNode) -> EventProcessor
+    def processor(event, hint):
+        # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
+        try:
+            errors = hint["exc_info"][1].errors
+        except (AttributeError, KeyError):
+            errors = None
+
+        request = event.setdefault("request", {})
+        request.update(
+            {
+                "api_target": "graphql",
+                **_request_info_from_transport(client.transport),
+            }
+        )
+
+        if _should_send_default_pii():
+            request["data"] = _data_from_document(document)
+            contexts = event.setdefault("contexts", {})
+            response = contexts.setdefault("response", {})
+            response.update(
+                {
+                    "data": {"errors": errors},
+                    "type": response,
+                }
+            )
+
+        return event
+
+    return processor


Test patch:
diff --git a/tests/integrations/gql/test_gql.py b/tests/integrations/gql/test_gql.py
new file mode 100644
--- /dev/null
+++ b/tests/integrations/gql/test_gql.py
@@ -0,0 +1,218 @@
+import pytest
+
+pytest.importorskip("gql")
+
+import responses
+from gql import gql
+from gql import Client
+from gql.transport.exceptions import TransportQueryError
+from gql.transport.requests import RequestsHTTPTransport
+from graphql import DocumentNode
+from sentry_sdk.integrations.gql import GQLIntegration
+from unittest.mock import MagicMock, patch
+
+
+class _MockClientBase(MagicMock):
+    """
+    Mocked version of GQL Client class, following same spec as GQL Client.
+    """
+
+    def __init__(self, *args, **kwargs):
+        kwargs["spec"] = Client
+        super().__init__(*args, **kwargs)
+
+    transport = MagicMock()
+
+
+@responses.activate
+def _execute_mock_query(response_json):
+    url = "http://example.com/graphql"
+    query_string = """
+        query Example {
+            example
+        }
+    """
+
+    # Mock the GraphQL server response
+    responses.add(
+        method=responses.POST,
+        url=url,
+        json=response_json,
+        status=200,
+    )
+
+    transport = RequestsHTTPTransport(url=url)
+    client = Client(transport=transport)
+    query = gql(query_string)
+
+    return client.execute(query)
+
+
+def _make_erroneous_query(capture_events):
+    """
+    Make an erroneous GraphQL query, and assert that the error was reraised, that
+    exactly one event was recorded, and that the exception recorded was a
+    TransportQueryError. Then, return the event to allow further verifications.
+    """
+    events = capture_events()
+    response_json = {"errors": ["something bad happened"]}
+
+    with pytest.raises(TransportQueryError):
+        _execute_mock_query(response_json)
+
+    assert (
+        len(events) == 1
+    ), "the sdk captured %d events, but 1 event was expected" % len(events)
+
+    (event,) = events
+    (exception,) = event["exception"]["values"]
+
+    assert (
+        exception["type"] == "TransportQueryError"
+    ), "%s was captured, but we expected a TransportQueryError" % exception(type)
+
+    assert "request" in event
+
+    return event
+
+
+def test_gql_init(sentry_init):
+    """
+    Integration test to ensure we can initialize the SDK with the GQL Integration
+    """
+    sentry_init(integrations=[GQLIntegration()])
+
+
+@patch("sentry_sdk.integrations.gql.Hub")
+def test_setup_once_patches_execute_and_patched_function_calls_original(_):
+    """
+    Unit test which ensures the following:
+        1. The GQLIntegration setup_once function patches the gql.Client.execute method
+        2. The patched gql.Client.execute method still calls the original method, and it
+           forwards its arguments to the original method.
+        3. The patched gql.Client.execute method returns the same value that the original
+           method returns.
+    """
+    original_method_return_value = MagicMock()
+
+    class OriginalMockClient(_MockClientBase):
+        """
+        This mock client always returns the mock original_method_return_value when a query
+        is executed. This can be used to simulate successful GraphQL queries.
+        """
+
+        execute = MagicMock(
+            spec=Client.execute, return_value=original_method_return_value
+        )
+
+    original_execute_method = OriginalMockClient.execute
+
+    with patch(
+        "sentry_sdk.integrations.gql.gql.Client", new=OriginalMockClient
+    ) as PatchedMockClient:  # noqa: N806
+        # Below line should patch the PatchedMockClient with Sentry SDK magic
+        GQLIntegration.setup_once()
+
+        # We expect GQLIntegration.setup_once to patch the execute method.
+        assert (
+            PatchedMockClient.execute is not original_execute_method
+        ), "execute method not patched"
+
+        # Now, let's instantiate a client and send it a query. Original execute still should get called.
+        mock_query = MagicMock(spec=DocumentNode)
+        client_instance = PatchedMockClient()
+        patched_method_return_value = client_instance.execute(mock_query)
+
+    # Here, we check that the original execute was called
+    original_execute_method.assert_called_once_with(client_instance, mock_query)
+
+    # Also, let's verify that the patched execute returns the expected value.
+    assert (
+        patched_method_return_value is original_method_return_value
+    ), "pathced execute method returns a different value than the original execute method"
+
+
+@patch("sentry_sdk.integrations.gql.event_from_exception")
+@patch("sentry_sdk.integrations.gql.Hub")
+def test_patched_gql_execute_captures_and_reraises_graphql_exception(
+    mock_hub, mock_event_from_exception
+):
+    """
+    Unit test which ensures that in the case that calling the execute method results in a
+    TransportQueryError (which gql raises when a GraphQL error occurs), the patched method
+    captures the event on the current Hub and it reraises the error.
+    """
+    mock_event_from_exception.return_value = (dict(), MagicMock())
+
+    class OriginalMockClient(_MockClientBase):
+        """
+        This mock client always raises a TransportQueryError when a GraphQL query is attempted.
+        This simulates a GraphQL query which results in errors.
+        """
+
+        execute = MagicMock(
+            spec=Client.execute, side_effect=TransportQueryError("query failed")
+        )
+
+    with patch(
+        "sentry_sdk.integrations.gql.gql.Client", new=OriginalMockClient
+    ) as PatchedMockClient:  # noqa: N806
+        # Below line should patch the PatchedMockClient with Sentry SDK magic
+        GQLIntegration.setup_once()
+
+        mock_query = MagicMock(spec=DocumentNode)
+        client_instance = PatchedMockClient()
+
+        # The error should still get raised even though we have instrumented the execute method.
+        with pytest.raises(TransportQueryError):
+            client_instance.execute(mock_query)
+
+    # However, we should have also captured the error on the hub.
+    mock_capture_event = mock_hub.current.capture_event
+    mock_capture_event.assert_called_once()
+
+
+def test_real_gql_request_no_error(sentry_init, capture_events):
+    """
+    Integration test verifying that the GQLIntegration works as expected with successful query.
+    """
+    sentry_init(integrations=[GQLIntegration()])
+    events = capture_events()
+
+    response_data = {"example": "This is the example"}
+    response_json = {"data": response_data}
+
+    result = _execute_mock_query(response_json)
+
+    assert (
+        result == response_data
+    ), "client.execute returned a different value from what it received from the server"
+    assert (
+        len(events) == 0
+    ), "the sdk captured an event, even though the query was successful"
+
+
+def test_real_gql_request_with_error_no_pii(sentry_init, capture_events):
+    """
+    Integration test verifying that the GQLIntegration works as expected with query resulting
+    in a GraphQL error, and that PII is not sent.
+    """
+    sentry_init(integrations=[GQLIntegration()])
+
+    event = _make_erroneous_query(capture_events)
+
+    assert "data" not in event["request"]
+    assert "response" not in event["contexts"]
+
+
+def test_real_gql_request_with_error_with_pii(sentry_init, capture_events):
+    """
+    Integration test verifying that the GQLIntegration works as expected with query resulting
+    in a GraphQL error, and that PII is not sent.
+    """
+    sentry_init(integrations=[GQLIntegration()], send_default_pii=True)
+
+    event = _make_erroneous_query(capture_events)
+
+    assert "data" in event["request"]
+    assert "response" in event["contexts"]