-
Notifications
You must be signed in to change notification settings - Fork 505
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add Ariadne GraphQL error integration (#2387)
Capture GraphQL errors when using Ariadne server side and add more context to them (request, response).
- Loading branch information
1 parent
b357fd5
commit 7c74ed3
Showing
4 changed files
with
553 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
name: Test ariadne | ||
|
||
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: ariadne, python ${{ matrix.python-version }}, ${{ matrix.os }} | ||
runs-on: ${{ matrix.os }} | ||
timeout-minutes: 30 | ||
|
||
strategy: | ||
fail-fast: false | ||
matrix: | ||
python-version: ["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 ariadne | ||
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 }}-ariadne" --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 ariadne 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
from importlib import import_module | ||
|
||
from sentry_sdk.hub import Hub, _should_send_default_pii | ||
from sentry_sdk.integrations import DidNotEnable, Integration | ||
from sentry_sdk.integrations.logging import ignore_logger | ||
from sentry_sdk.integrations.modules import _get_installed_modules | ||
from sentry_sdk.integrations._wsgi_common import request_body_within_bounds | ||
from sentry_sdk.utils import ( | ||
capture_internal_exceptions, | ||
event_from_exception, | ||
parse_version, | ||
) | ||
from sentry_sdk._types import TYPE_CHECKING | ||
|
||
try: | ||
# importing like this is necessary due to name shadowing in ariadne | ||
# (ariadne.graphql is also a function) | ||
ariadne_graphql = import_module("ariadne.graphql") | ||
except ImportError: | ||
raise DidNotEnable("ariadne is not installed") | ||
|
||
|
||
if TYPE_CHECKING: | ||
from typing import Any, Dict, List, Optional | ||
from ariadne.types import GraphQLError, GraphQLResult, GraphQLSchema, QueryParser # type: ignore | ||
from graphql.language.ast import DocumentNode # type: ignore | ||
from sentry_sdk._types import EventProcessor | ||
|
||
|
||
class AriadneIntegration(Integration): | ||
identifier = "ariadne" | ||
|
||
@staticmethod | ||
def setup_once(): | ||
# type: () -> None | ||
installed_packages = _get_installed_modules() | ||
version = parse_version(installed_packages["ariadne"]) | ||
|
||
if version is None: | ||
raise DidNotEnable("Unparsable ariadne version: {}".format(version)) | ||
|
||
if version < (0, 20): | ||
raise DidNotEnable("ariadne 0.20 or newer required.") | ||
|
||
ignore_logger("ariadne") | ||
|
||
_patch_graphql() | ||
|
||
|
||
def _patch_graphql(): | ||
# type: () -> None | ||
old_parse_query = ariadne_graphql.parse_query | ||
old_handle_errors = ariadne_graphql.handle_graphql_errors | ||
old_handle_query_result = ariadne_graphql.handle_query_result | ||
|
||
def _sentry_patched_parse_query(context_value, query_parser, data): | ||
# type: (Optional[Any], Optional[QueryParser], Any) -> DocumentNode | ||
hub = Hub.current | ||
integration = hub.get_integration(AriadneIntegration) | ||
if integration is None: | ||
return old_parse_query(context_value, query_parser, data) | ||
|
||
with hub.configure_scope() as scope: | ||
event_processor = _make_request_event_processor(data) | ||
scope.add_event_processor(event_processor) | ||
|
||
result = old_parse_query(context_value, query_parser, data) | ||
return result | ||
|
||
def _sentry_patched_handle_graphql_errors(errors, *args, **kwargs): | ||
# type: (List[GraphQLError], Any, Any) -> GraphQLResult | ||
hub = Hub.current | ||
integration = hub.get_integration(AriadneIntegration) | ||
if integration is None: | ||
return old_handle_errors(errors, *args, **kwargs) | ||
|
||
result = old_handle_errors(errors, *args, **kwargs) | ||
|
||
with hub.configure_scope() as scope: | ||
event_processor = _make_response_event_processor(result[1]) | ||
scope.add_event_processor(event_processor) | ||
|
||
if hub.client: | ||
with capture_internal_exceptions(): | ||
for error in errors: | ||
event, hint = event_from_exception( | ||
error, | ||
client_options=hub.client.options, | ||
mechanism={ | ||
"type": integration.identifier, | ||
"handled": False, | ||
}, | ||
) | ||
hub.capture_event(event, hint=hint) | ||
|
||
return result | ||
|
||
def _sentry_patched_handle_query_result(result, *args, **kwargs): | ||
# type: (Any, Any, Any) -> GraphQLResult | ||
hub = Hub.current | ||
integration = hub.get_integration(AriadneIntegration) | ||
if integration is None: | ||
return old_handle_query_result(result, *args, **kwargs) | ||
|
||
query_result = old_handle_query_result(result, *args, **kwargs) | ||
|
||
with hub.configure_scope() as scope: | ||
event_processor = _make_response_event_processor(query_result[1]) | ||
scope.add_event_processor(event_processor) | ||
|
||
if hub.client: | ||
with capture_internal_exceptions(): | ||
for error in result.errors or []: | ||
event, hint = event_from_exception( | ||
error, | ||
client_options=hub.client.options, | ||
mechanism={ | ||
"type": integration.identifier, | ||
"handled": False, | ||
}, | ||
) | ||
hub.capture_event(event, hint=hint) | ||
|
||
return query_result | ||
|
||
ariadne_graphql.parse_query = _sentry_patched_parse_query # type: ignore | ||
ariadne_graphql.handle_graphql_errors = _sentry_patched_handle_graphql_errors # type: ignore | ||
ariadne_graphql.handle_query_result = _sentry_patched_handle_query_result # type: ignore | ||
|
||
|
||
def _make_request_event_processor(data): | ||
# type: (GraphQLSchema) -> EventProcessor | ||
"""Add request data and api_target to events.""" | ||
|
||
def inner(event, hint): | ||
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] | ||
if not isinstance(data, dict): | ||
return event | ||
|
||
with capture_internal_exceptions(): | ||
try: | ||
content_length = int( | ||
(data.get("headers") or {}).get("Content-Length", 0) | ||
) | ||
except (TypeError, ValueError): | ||
return event | ||
|
||
if _should_send_default_pii() and request_body_within_bounds( | ||
Hub.current.client, content_length | ||
): | ||
request_info = event.setdefault("request", {}) | ||
request_info["api_target"] = "graphql" | ||
request_info["data"] = data | ||
|
||
elif event.get("request", {}).get("data"): | ||
del event["request"]["data"] | ||
|
||
return event | ||
|
||
return inner | ||
|
||
|
||
def _make_response_event_processor(response): | ||
# type: (Dict[str, Any]) -> EventProcessor | ||
"""Add response data to the event's response context.""" | ||
|
||
def inner(event, hint): | ||
# type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] | ||
with capture_internal_exceptions(): | ||
if _should_send_default_pii() and response.get("errors"): | ||
contexts = event.setdefault("contexts", {}) | ||
contexts["response"] = { | ||
"data": response, | ||
} | ||
|
||
return event | ||
|
||
return inner |
Oops, something went wrong.