Skip to content

Commit 7c74ed3

Browse files
authored
Add Ariadne GraphQL error integration (#2387)
Capture GraphQL errors when using Ariadne server side and add more context to them (request, response).
1 parent b357fd5 commit 7c74ed3

File tree

4 files changed

+553
-0
lines changed

4 files changed

+553
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Test ariadne
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: ariadne, 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.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 ariadne
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 }}-ariadne" --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 ariadne 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/ariadne.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from importlib import import_module
2+
3+
from sentry_sdk.hub import Hub, _should_send_default_pii
4+
from sentry_sdk.integrations import DidNotEnable, Integration
5+
from sentry_sdk.integrations.logging import ignore_logger
6+
from sentry_sdk.integrations.modules import _get_installed_modules
7+
from sentry_sdk.integrations._wsgi_common import request_body_within_bounds
8+
from sentry_sdk.utils import (
9+
capture_internal_exceptions,
10+
event_from_exception,
11+
parse_version,
12+
)
13+
from sentry_sdk._types import TYPE_CHECKING
14+
15+
try:
16+
# importing like this is necessary due to name shadowing in ariadne
17+
# (ariadne.graphql is also a function)
18+
ariadne_graphql = import_module("ariadne.graphql")
19+
except ImportError:
20+
raise DidNotEnable("ariadne is not installed")
21+
22+
23+
if TYPE_CHECKING:
24+
from typing import Any, Dict, List, Optional
25+
from ariadne.types import GraphQLError, GraphQLResult, GraphQLSchema, QueryParser # type: ignore
26+
from graphql.language.ast import DocumentNode # type: ignore
27+
from sentry_sdk._types import EventProcessor
28+
29+
30+
class AriadneIntegration(Integration):
31+
identifier = "ariadne"
32+
33+
@staticmethod
34+
def setup_once():
35+
# type: () -> None
36+
installed_packages = _get_installed_modules()
37+
version = parse_version(installed_packages["ariadne"])
38+
39+
if version is None:
40+
raise DidNotEnable("Unparsable ariadne version: {}".format(version))
41+
42+
if version < (0, 20):
43+
raise DidNotEnable("ariadne 0.20 or newer required.")
44+
45+
ignore_logger("ariadne")
46+
47+
_patch_graphql()
48+
49+
50+
def _patch_graphql():
51+
# type: () -> None
52+
old_parse_query = ariadne_graphql.parse_query
53+
old_handle_errors = ariadne_graphql.handle_graphql_errors
54+
old_handle_query_result = ariadne_graphql.handle_query_result
55+
56+
def _sentry_patched_parse_query(context_value, query_parser, data):
57+
# type: (Optional[Any], Optional[QueryParser], Any) -> DocumentNode
58+
hub = Hub.current
59+
integration = hub.get_integration(AriadneIntegration)
60+
if integration is None:
61+
return old_parse_query(context_value, query_parser, data)
62+
63+
with hub.configure_scope() as scope:
64+
event_processor = _make_request_event_processor(data)
65+
scope.add_event_processor(event_processor)
66+
67+
result = old_parse_query(context_value, query_parser, data)
68+
return result
69+
70+
def _sentry_patched_handle_graphql_errors(errors, *args, **kwargs):
71+
# type: (List[GraphQLError], Any, Any) -> GraphQLResult
72+
hub = Hub.current
73+
integration = hub.get_integration(AriadneIntegration)
74+
if integration is None:
75+
return old_handle_errors(errors, *args, **kwargs)
76+
77+
result = old_handle_errors(errors, *args, **kwargs)
78+
79+
with hub.configure_scope() as scope:
80+
event_processor = _make_response_event_processor(result[1])
81+
scope.add_event_processor(event_processor)
82+
83+
if hub.client:
84+
with capture_internal_exceptions():
85+
for error in errors:
86+
event, hint = event_from_exception(
87+
error,
88+
client_options=hub.client.options,
89+
mechanism={
90+
"type": integration.identifier,
91+
"handled": False,
92+
},
93+
)
94+
hub.capture_event(event, hint=hint)
95+
96+
return result
97+
98+
def _sentry_patched_handle_query_result(result, *args, **kwargs):
99+
# type: (Any, Any, Any) -> GraphQLResult
100+
hub = Hub.current
101+
integration = hub.get_integration(AriadneIntegration)
102+
if integration is None:
103+
return old_handle_query_result(result, *args, **kwargs)
104+
105+
query_result = old_handle_query_result(result, *args, **kwargs)
106+
107+
with hub.configure_scope() as scope:
108+
event_processor = _make_response_event_processor(query_result[1])
109+
scope.add_event_processor(event_processor)
110+
111+
if hub.client:
112+
with capture_internal_exceptions():
113+
for error in result.errors or []:
114+
event, hint = event_from_exception(
115+
error,
116+
client_options=hub.client.options,
117+
mechanism={
118+
"type": integration.identifier,
119+
"handled": False,
120+
},
121+
)
122+
hub.capture_event(event, hint=hint)
123+
124+
return query_result
125+
126+
ariadne_graphql.parse_query = _sentry_patched_parse_query # type: ignore
127+
ariadne_graphql.handle_graphql_errors = _sentry_patched_handle_graphql_errors # type: ignore
128+
ariadne_graphql.handle_query_result = _sentry_patched_handle_query_result # type: ignore
129+
130+
131+
def _make_request_event_processor(data):
132+
# type: (GraphQLSchema) -> EventProcessor
133+
"""Add request data and api_target to events."""
134+
135+
def inner(event, hint):
136+
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
137+
if not isinstance(data, dict):
138+
return event
139+
140+
with capture_internal_exceptions():
141+
try:
142+
content_length = int(
143+
(data.get("headers") or {}).get("Content-Length", 0)
144+
)
145+
except (TypeError, ValueError):
146+
return event
147+
148+
if _should_send_default_pii() and request_body_within_bounds(
149+
Hub.current.client, content_length
150+
):
151+
request_info = event.setdefault("request", {})
152+
request_info["api_target"] = "graphql"
153+
request_info["data"] = data
154+
155+
elif event.get("request", {}).get("data"):
156+
del event["request"]["data"]
157+
158+
return event
159+
160+
return inner
161+
162+
163+
def _make_response_event_processor(response):
164+
# type: (Dict[str, Any]) -> EventProcessor
165+
"""Add response data to the event's response context."""
166+
167+
def inner(event, hint):
168+
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any]
169+
with capture_internal_exceptions():
170+
if _should_send_default_pii() and response.get("errors"):
171+
contexts = event.setdefault("contexts", {})
172+
contexts["response"] = {
173+
"data": response,
174+
}
175+
176+
return event
177+
178+
return inner

0 commit comments

Comments
 (0)