Skip to content

Commit 6908aad

Browse files
Add GraphQL client integration (#2368)
* 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 <[email protected]> * Address code review * TYPE_CHECKING from sentry_sdk._types Co-authored-by: Ivana Kellyerova <[email protected]> --------- Co-authored-by: Ivana Kellyerova <[email protected]>
1 parent 641822d commit 6908aad

File tree

4 files changed

+450
-0
lines changed

4 files changed

+450
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Test gql
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- release/**
8+
9+
pull_request:
10+
11+
# Cancel in progress workflows on pull_requests.
12+
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
BUILD_CACHE_KEY: ${{ github.sha }}
22+
CACHED_BUILD_PATHS: |
23+
${{ github.workspace }}/dist-serverless
24+
25+
jobs:
26+
test:
27+
name: gql, python ${{ matrix.python-version }}, ${{ matrix.os }}
28+
runs-on: ${{ matrix.os }}
29+
timeout-minutes: 30
30+
31+
strategy:
32+
fail-fast: false
33+
matrix:
34+
python-version: ["3.7","3.8","3.9","3.10","3.11"]
35+
# python3.6 reached EOL and is no longer being supported on
36+
# new versions of hosted runners on Github Actions
37+
# ubuntu-20.04 is the last version that supported python3.6
38+
# see https://github.com/actions/setup-python/issues/544#issuecomment-1332535877
39+
os: [ubuntu-20.04]
40+
41+
steps:
42+
- uses: actions/checkout@v4
43+
- uses: actions/setup-python@v4
44+
with:
45+
python-version: ${{ matrix.python-version }}
46+
47+
- name: Setup Test Env
48+
run: |
49+
pip install coverage "tox>=3,<4"
50+
51+
- name: Test gql
52+
uses: nick-fields/retry@v2
53+
with:
54+
timeout_minutes: 15
55+
max_attempts: 2
56+
retry_wait_seconds: 5
57+
shell: bash
58+
command: |
59+
set -x # print commands that are executed
60+
coverage erase
61+
62+
# Run tests
63+
./scripts/runtox.sh "py${{ matrix.python-version }}-gql" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch &&
64+
coverage combine .coverage* &&
65+
coverage xml -i
66+
67+
- uses: codecov/codecov-action@v3
68+
with:
69+
token: ${{ secrets.CODECOV_TOKEN }}
70+
files: coverage.xml
71+
72+
73+
check_required_tests:
74+
name: All gql tests passed or skipped
75+
needs: test
76+
# Always run this, even if a dependent job failed
77+
if: always()
78+
runs-on: ubuntu-20.04
79+
steps:
80+
- name: Check for failures
81+
if: contains(needs.test.result, 'failure')
82+
run: |
83+
echo "One of the dependent jobs has failed. You may need to re-run it." && exit 1

sentry_sdk/integrations/gql.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from sentry_sdk.utils import event_from_exception, parse_version
2+
from sentry_sdk.hub import Hub, _should_send_default_pii
3+
from sentry_sdk.integrations import DidNotEnable, Integration
4+
5+
try:
6+
import gql # type: ignore[import]
7+
from graphql import print_ast, get_operation_ast, DocumentNode, VariableDefinitionNode # type: ignore[import]
8+
from gql.transport import Transport, AsyncTransport # type: ignore[import]
9+
from gql.transport.exceptions import TransportQueryError # type: ignore[import]
10+
except ImportError:
11+
raise DidNotEnable("gql is not installed")
12+
13+
from sentry_sdk._types import TYPE_CHECKING
14+
15+
if TYPE_CHECKING:
16+
from typing import Any, Dict, Tuple, Union
17+
from sentry_sdk._types import EventProcessor
18+
19+
EventDataType = Dict[str, Union[str, Tuple[VariableDefinitionNode, ...]]]
20+
21+
MIN_GQL_VERSION = (3, 4, 1)
22+
23+
24+
class GQLIntegration(Integration):
25+
identifier = "gql"
26+
27+
@staticmethod
28+
def setup_once():
29+
# type: () -> None
30+
gql_version = parse_version(gql.__version__)
31+
if gql_version is None or gql_version < MIN_GQL_VERSION:
32+
raise DidNotEnable(
33+
"GQLIntegration is only supported for GQL versions %s and above."
34+
% ".".join(str(num) for num in MIN_GQL_VERSION)
35+
)
36+
_patch_execute()
37+
38+
39+
def _data_from_document(document):
40+
# type: (DocumentNode) -> EventDataType
41+
try:
42+
operation_ast = get_operation_ast(document)
43+
data = {"query": print_ast(document)} # type: EventDataType
44+
45+
if operation_ast is not None:
46+
data["variables"] = operation_ast.variable_definitions
47+
if operation_ast.name is not None:
48+
data["operationName"] = operation_ast.name.value
49+
50+
return data
51+
except (AttributeError, TypeError):
52+
return dict()
53+
54+
55+
def _transport_method(transport):
56+
# type: (Union[Transport, AsyncTransport]) -> str
57+
"""
58+
The RequestsHTTPTransport allows defining the HTTP method; all
59+
other transports use POST.
60+
"""
61+
try:
62+
return transport.method
63+
except AttributeError:
64+
return "POST"
65+
66+
67+
def _request_info_from_transport(transport):
68+
# type: (Union[Transport, AsyncTransport, None]) -> Dict[str, str]
69+
if transport is None:
70+
return {}
71+
72+
request_info = {
73+
"method": _transport_method(transport),
74+
}
75+
76+
try:
77+
request_info["url"] = transport.url
78+
except AttributeError:
79+
pass
80+
81+
return request_info
82+
83+
84+
def _patch_execute():
85+
# type: () -> None
86+
real_execute = gql.Client.execute
87+
88+
def sentry_patched_execute(self, document, *args, **kwargs):
89+
# type: (gql.Client, DocumentNode, Any, Any) -> Any
90+
hub = Hub.current
91+
if hub.get_integration(GQLIntegration) is None:
92+
return real_execute(self, document, *args, **kwargs)
93+
94+
with Hub.current.configure_scope() as scope:
95+
scope.add_event_processor(_make_gql_event_processor(self, document))
96+
97+
try:
98+
return real_execute(self, document, *args, **kwargs)
99+
except TransportQueryError as e:
100+
event, hint = event_from_exception(
101+
e,
102+
client_options=hub.client.options if hub.client is not None else None,
103+
mechanism={"type": "gql", "handled": False},
104+
)
105+
106+
hub.capture_event(event, hint)
107+
raise e
108+
109+
gql.Client.execute = sentry_patched_execute
110+
111+
112+
def _make_gql_event_processor(client, document):
113+
# type: (gql.Client, DocumentNode) -> EventProcessor
114+
def processor(event, hint):
115+
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
116+
try:
117+
errors = hint["exc_info"][1].errors
118+
except (AttributeError, KeyError):
119+
errors = None
120+
121+
request = event.setdefault("request", {})
122+
request.update(
123+
{
124+
"api_target": "graphql",
125+
**_request_info_from_transport(client.transport),
126+
}
127+
)
128+
129+
if _should_send_default_pii():
130+
request["data"] = _data_from_document(document)
131+
contexts = event.setdefault("contexts", {})
132+
response = contexts.setdefault("response", {})
133+
response.update(
134+
{
135+
"data": {"errors": errors},
136+
"type": response,
137+
}
138+
)
139+
140+
return event
141+
142+
return processor

0 commit comments

Comments
 (0)