Skip to content

Commit

Permalink
Add GraphQL client integration (getsentry#2368)
Browse files Browse the repository at this point in the history
* Monkeypatch

* Sending actual errors now

* Fix mypy typing

* Add GQL requirements to Tox

* Add Tox dependencies

* Fix mypy

* More meaningful patched function name

* some basic unit tests

* Created GQL Tox env

* Updated YAML for CI

* Added importorskip for gql tests

* More unit tests

* Improved mocking for unit tests

* Explain each test

* added two integration tests for good measure

* Skip loading gql tests in python below 3.7

* Fix module name

* Actually should have fixed module name now

* Install optional gql dependencies in tox

* Fix error in Py 3.7

* Ignore capitalized variable

* Added doc comment to pytest_ignore_collect

* Check successful gql import

* Switch to type comments

* Made test loadable in Python 2

* Added version check

* Make sure integration is there before doing sentry stuff

* Removed breakpoint

* Using EventProcessor

* Fix typing

* Change to version comment

Co-authored-by: Ivana Kellyerova <ivana.kellyerova@sentry.io>

* Address code review

* TYPE_CHECKING from sentry_sdk._types

Co-authored-by: Ivana Kellyerova <ivana.kellyerova@sentry.io>

---------

Co-authored-by: Ivana Kellyerova <ivana.kellyerova@sentry.io>
  • Loading branch information
szokeasaurusrex and sentrivana authored Sep 26, 2023
1 parent 641822d commit 6908aad
Show file tree
Hide file tree
Showing 4 changed files with 450 additions and 0 deletions.
83 changes: 83 additions & 0 deletions .github/workflows/test-integration-gql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Test gql

on:
push:
branches:
- master
- release/**

pull_request:

# Cancel in progress workflows on pull_requests.
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions:
contents: read

env:
BUILD_CACHE_KEY: ${{ github.sha }}
CACHED_BUILD_PATHS: |
${{ github.workspace }}/dist-serverless
jobs:
test:
name: gql, python ${{ matrix.python-version }}, ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30

strategy:
fail-fast: false
matrix:
python-version: ["3.7","3.8","3.9","3.10","3.11"]
# python3.6 reached EOL and is no longer being supported on
# new versions of hosted runners on Github Actions
# ubuntu-20.04 is the last version that supported python3.6
# see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877
os: [ubuntu-20.04]

steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}

- name: Setup Test Env
run: |
pip install coverage "tox>=3,<4"
- name: Test gql
uses: nick-fields/retry@v2
with:
timeout_minutes: 15
max_attempts: 2
retry_wait_seconds: 5
shell: bash
command: |
set -x # print commands that are executed
coverage erase
# Run tests
./scripts/runtox.sh "py${{ matrix.python-version }}-gql" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch &&
coverage combine .coverage* &&
coverage xml -i
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml


check_required_tests:
name: All gql tests passed or skipped
needs: test
# Always run this, even if a dependent job failed
if: always()
runs-on: ubuntu-20.04
steps:
- name: Check for failures
if: contains(needs.test.result, 'failure')
run: |
echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1
142 changes: 142 additions & 0 deletions sentry_sdk/integrations/gql.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 6908aad

Please sign in to comment.