diff --git a/.github/workflows/test-integration-strawberry.yml b/.github/workflows/test-integration-strawberry.yml new file mode 100644 index 0000000000..b0e30a8f5b --- /dev/null +++ b/.github/workflows/test-integration-strawberry.yml @@ -0,0 +1,83 @@ +name: Test strawberry + +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: strawberry, 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 strawberry + 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 }}-strawberry" --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 strawberry 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 diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index accfa283fc..5aa04be181 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -170,6 +170,13 @@ class OP: FUNCTION = "function" FUNCTION_AWS = "function.aws" FUNCTION_GCP = "function.gcp" + GRAPHQL_EXECUTE = "graphql.execute" + GRAPHQL_MUTATION = "graphql.mutation" + GRAPHQL_PARSE = "graphql.parse" + GRAPHQL_RESOLVE = "graphql.resolve" + GRAPHQL_SUBSCRIPTION = "graphql.subscription" + GRAPHQL_QUERY = "graphql.query" + GRAPHQL_VALIDATE = "graphql.validate" GRPC_CLIENT = "grpc.client" GRPC_SERVER = "grpc.server" HTTP_CLIENT = "http.client" diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py new file mode 100644 index 0000000000..63ddc44f25 --- /dev/null +++ b/sentry_sdk/integrations/strawberry.py @@ -0,0 +1,404 @@ +import hashlib +from functools import cached_property +from inspect import isawaitable +from sentry_sdk import configure_scope, start_span +from sentry_sdk.consts import OP +from sentry_sdk.integrations import Integration, DidNotEnable +from sentry_sdk.integrations.logging import ignore_logger +from sentry_sdk.integrations.modules import _get_installed_modules +from sentry_sdk.hub import Hub, _should_send_default_pii +from sentry_sdk.utils import ( + capture_internal_exceptions, + event_from_exception, + logger, + parse_version, +) +from sentry_sdk._types import TYPE_CHECKING + +try: + import strawberry.schema.schema as strawberry_schema # type: ignore + from strawberry import Schema + from strawberry.extensions import SchemaExtension # type: ignore + from strawberry.extensions.tracing.utils import should_skip_tracing as strawberry_should_skip_tracing # type: ignore + from strawberry.extensions.tracing import ( # type: ignore + SentryTracingExtension as StrawberrySentryAsyncExtension, + SentryTracingExtensionSync as StrawberrySentrySyncExtension, + ) + from strawberry.http import async_base_view, sync_base_view # type: ignore +except ImportError: + raise DidNotEnable("strawberry-graphql is not installed") + +if TYPE_CHECKING: + from typing import Any, Callable, Dict, Generator, List, Optional + from graphql import GraphQLError, GraphQLResolveInfo # type: ignore + from strawberry.http import GraphQLHTTPResponse + from strawberry.types import ExecutionContext, ExecutionResult # type: ignore + from sentry_sdk._types import EventProcessor + + +ignore_logger("strawberry.execution") + + +class StrawberryIntegration(Integration): + identifier = "strawberry" + + def __init__(self, async_execution=None): + # type: (Optional[bool]) -> None + if async_execution not in (None, False, True): + raise ValueError( + 'Invalid value for async_execution: "{}" (must be bool)'.format( + async_execution + ) + ) + self.async_execution = async_execution + + @staticmethod + def setup_once(): + # type: () -> None + installed_packages = _get_installed_modules() + version = parse_version(installed_packages["strawberry-graphql"]) + + if version is None: + raise DidNotEnable( + "Unparsable strawberry-graphql version: {}".format(version) + ) + + if version < (0, 209, 5): + raise DidNotEnable("strawberry-graphql 0.209.5 or newer required.") + + _patch_schema_init() + _patch_execute() + _patch_views() + + +def _patch_schema_init(): + # type: () -> None + old_schema_init = Schema.__init__ + + def _sentry_patched_schema_init(self, *args, **kwargs): + # type: (Schema, Any, Any) -> None + integration = Hub.current.get_integration(StrawberryIntegration) + if integration is None: + return old_schema_init(self, *args, **kwargs) + + extensions = kwargs.get("extensions") or [] + + if integration.async_execution is not None: + should_use_async_extension = integration.async_execution + else: + # try to figure it out ourselves + should_use_async_extension = _guess_if_using_async(extensions) + + logger.info( + "Assuming strawberry is running %s. If not, initialize it as StrawberryIntegration(async_execution=%s).", + "async" if should_use_async_extension else "sync", + "False" if should_use_async_extension else "True", + ) + + # remove the built in strawberry sentry extension, if present + extensions = [ + extension + for extension in extensions + if extension + not in (StrawberrySentryAsyncExtension, StrawberrySentrySyncExtension) + ] + + # add our extension + extensions.append( + SentryAsyncExtension if should_use_async_extension else SentrySyncExtension + ) + + kwargs["extensions"] = extensions + + return old_schema_init(self, *args, **kwargs) + + Schema.__init__ = _sentry_patched_schema_init + + +class SentryAsyncExtension(SchemaExtension): # type: ignore + def __init__( + self, + *, + execution_context=None, + ): + # type: (Any, Optional[ExecutionContext]) -> None + if execution_context: + self.execution_context = execution_context + + @cached_property + def _resource_name(self): + # type: () -> str + query_hash = self.hash_query(self.execution_context.query) + + if self.execution_context.operation_name: + return "{}:{}".format(self.execution_context.operation_name, query_hash) + + return query_hash + + def hash_query(self, query): + # type: (str) -> str + return hashlib.md5(query.encode("utf-8")).hexdigest() + + def on_operation(self): + # type: () -> Generator[None, None, None] + self._operation_name = self.execution_context.operation_name + + operation_type = "query" + op = OP.GRAPHQL_QUERY + + if self.execution_context.query.strip().startswith("mutation"): + operation_type = "mutation" + op = OP.GRAPHQL_MUTATION + elif self.execution_context.query.strip().startswith("subscription"): + operation_type = "subscription" + op = OP.GRAPHQL_SUBSCRIPTION + + description = operation_type + if self._operation_name: + description += " {}".format(self._operation_name) + + Hub.current.add_breadcrumb( + category="graphql.operation", + data={ + "operation_name": self._operation_name, + "operation_type": operation_type, + }, + ) + + with configure_scope() as scope: + if scope.span: + self.graphql_span = scope.span.start_child( + op=op, description=description + ) + else: + self.graphql_span = start_span(op=op, description=description) + + self.graphql_span.set_data("graphql.operation.type", operation_type) + self.graphql_span.set_data("graphql.operation.name", self._operation_name) + self.graphql_span.set_data("graphql.document", self.execution_context.query) + self.graphql_span.set_data("graphql.resource_name", self._resource_name) + + yield + + self.graphql_span.finish() + + def on_validate(self): + # type: () -> Generator[None, None, None] + self.validation_span = self.graphql_span.start_child( + op=OP.GRAPHQL_VALIDATE, description="validation" + ) + + yield + + self.validation_span.finish() + + def on_parse(self): + # type: () -> Generator[None, None, None] + self.parsing_span = self.graphql_span.start_child( + op=OP.GRAPHQL_PARSE, description="parsing" + ) + + yield + + self.parsing_span.finish() + + def should_skip_tracing(self, _next, info): + # type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], GraphQLResolveInfo) -> bool + return strawberry_should_skip_tracing(_next, info) + + async def _resolve(self, _next, root, info, *args, **kwargs): + # type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any + result = _next(root, info, *args, **kwargs) + + if isawaitable(result): + result = await result + + return result + + async def resolve(self, _next, root, info, *args, **kwargs): + # type: (Callable[[Any, GraphQLResolveInfo, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any + if self.should_skip_tracing(_next, info): + return await self._resolve(_next, root, info, *args, **kwargs) + + field_path = "{}.{}".format(info.parent_type, info.field_name) + + with self.graphql_span.start_child( + op=OP.GRAPHQL_RESOLVE, description="resolving {}".format(field_path) + ) as span: + span.set_data("graphql.field_name", info.field_name) + span.set_data("graphql.parent_type", info.parent_type.name) + span.set_data("graphql.field_path", field_path) + span.set_data("graphql.path", ".".join(map(str, info.path.as_list()))) + + return await self._resolve(_next, root, info, *args, **kwargs) + + +class SentrySyncExtension(SentryAsyncExtension): + def resolve(self, _next, root, info, *args, **kwargs): + # type: (Callable[[Any, Any, Any, Any], Any], Any, GraphQLResolveInfo, str, Any) -> Any + if self.should_skip_tracing(_next, info): + return _next(root, info, *args, **kwargs) + + field_path = "{}.{}".format(info.parent_type, info.field_name) + + with self.graphql_span.start_child( + op=OP.GRAPHQL_RESOLVE, description="resolving {}".format(field_path) + ) as span: + span.set_data("graphql.field_name", info.field_name) + span.set_data("graphql.parent_type", info.parent_type.name) + span.set_data("graphql.field_path", field_path) + span.set_data("graphql.path", ".".join(map(str, info.path.as_list()))) + + return _next(root, info, *args, **kwargs) + + +def _patch_execute(): + # type: () -> None + old_execute_async = strawberry_schema.execute + old_execute_sync = strawberry_schema.execute_sync + + async def _sentry_patched_execute_async(*args, **kwargs): + # type: (Any, Any) -> ExecutionResult + hub = Hub.current + integration = hub.get_integration(StrawberryIntegration) + if integration is None: + return await old_execute_async(*args, **kwargs) + + result = await old_execute_async(*args, **kwargs) + + if "execution_context" in kwargs and result.errors: + with hub.configure_scope() as scope: + event_processor = _make_request_event_processor( + kwargs["execution_context"] + ) + scope.add_event_processor(event_processor) + + return result + + def _sentry_patched_execute_sync(*args, **kwargs): + # type: (Any, Any) -> ExecutionResult + hub = Hub.current + integration = hub.get_integration(StrawberryIntegration) + if integration is None: + return old_execute_sync(*args, **kwargs) + + result = old_execute_sync(*args, **kwargs) + + if "execution_context" in kwargs and result.errors: + with hub.configure_scope() as scope: + event_processor = _make_request_event_processor( + kwargs["execution_context"] + ) + scope.add_event_processor(event_processor) + + return result + + strawberry_schema.execute = _sentry_patched_execute_async + strawberry_schema.execute_sync = _sentry_patched_execute_sync + + +def _patch_views(): + # type: () -> None + old_async_view_handle_errors = async_base_view.AsyncBaseHTTPView._handle_errors + old_sync_view_handle_errors = sync_base_view.SyncBaseHTTPView._handle_errors + + def _sentry_patched_async_view_handle_errors(self, errors, response_data): + # type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None + old_async_view_handle_errors(self, errors, response_data) + _sentry_patched_handle_errors(self, errors, response_data) + + def _sentry_patched_sync_view_handle_errors(self, errors, response_data): + # type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None + old_sync_view_handle_errors(self, errors, response_data) + _sentry_patched_handle_errors(self, errors, response_data) + + def _sentry_patched_handle_errors(self, errors, response_data): + # type: (Any, List[GraphQLError], GraphQLHTTPResponse) -> None + hub = Hub.current + integration = hub.get_integration(StrawberryIntegration) + if integration is None: + return + + if not errors: + return + + with hub.configure_scope() as scope: + event_processor = _make_response_event_processor(response_data) + scope.add_event_processor(event_processor) + + with capture_internal_exceptions(): + for error in errors: + event, hint = event_from_exception( + error, + client_options=hub.client.options if hub.client else None, + mechanism={ + "type": integration.identifier, + "handled": False, + }, + ) + hub.capture_event(event, hint=hint) + + async_base_view.AsyncBaseHTTPView._handle_errors = ( + _sentry_patched_async_view_handle_errors + ) + sync_base_view.SyncBaseHTTPView._handle_errors = ( + _sentry_patched_sync_view_handle_errors + ) + + +def _make_request_event_processor(execution_context): + # type: (ExecutionContext) -> EventProcessor + + def inner(event, hint): + # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + with capture_internal_exceptions(): + if _should_send_default_pii(): + request_data = event.setdefault("request", {}) + request_data["api_target"] = "graphql" + + if not request_data.get("data"): + request_data["data"] = {"query": execution_context.query} + + if execution_context.variables: + request_data["data"]["variables"] = execution_context.variables + if execution_context.operation_name: + request_data["data"][ + "operationName" + ] = execution_context.operation_name + + else: + try: + del event["request"]["data"] + except (KeyError, TypeError): + pass + + return event + + return inner + + +def _make_response_event_processor(response_data): + # type: (GraphQLHTTPResponse) -> EventProcessor + + def inner(event, hint): + # type: (Dict[str, Any], Dict[str, Any]) -> Dict[str, Any] + with capture_internal_exceptions(): + if _should_send_default_pii(): + contexts = event.setdefault("contexts", {}) + contexts["response"] = {"data": response_data} + + return event + + return inner + + +def _guess_if_using_async(extensions): + # type: (List[SchemaExtension]) -> bool + if StrawberrySentryAsyncExtension in extensions: + return True + elif StrawberrySentrySyncExtension in extensions: + return False + + return bool( + {"starlette", "starlite", "litestar", "fastapi"} & set(_get_installed_modules()) + ) diff --git a/tests/integrations/strawberry/__init__.py b/tests/integrations/strawberry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/integrations/strawberry/test_strawberry_py3.py b/tests/integrations/strawberry/test_strawberry_py3.py new file mode 100644 index 0000000000..b357779461 --- /dev/null +++ b/tests/integrations/strawberry/test_strawberry_py3.py @@ -0,0 +1,593 @@ +import pytest + +strawberry = pytest.importorskip("strawberry") +pytest.importorskip("fastapi") +pytest.importorskip("flask") + +from unittest import mock + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from flask import Flask +from strawberry.extensions.tracing import ( + SentryTracingExtension, + SentryTracingExtensionSync, +) +from strawberry.fastapi import GraphQLRouter +from strawberry.flask.views import GraphQLView + +from sentry_sdk.consts import OP +from sentry_sdk.integrations.fastapi import FastApiIntegration +from sentry_sdk.integrations.flask import FlaskIntegration +from sentry_sdk.integrations.starlette import StarletteIntegration +from sentry_sdk.integrations.strawberry import ( + StrawberryIntegration, + SentryAsyncExtension, + SentrySyncExtension, +) + + +parameterize_strawberry_test = pytest.mark.parametrize( + "client_factory,async_execution,framework_integrations", + ( + ( + "async_app_client_factory", + True, + [FastApiIntegration(), StarletteIntegration()], + ), + ("sync_app_client_factory", False, [FlaskIntegration()]), + ), +) + + +@strawberry.type +class Query: + @strawberry.field + def hello(self) -> str: + return "Hello World" + + @strawberry.field + def error(self) -> int: + return 1 / 0 + + +@strawberry.type +class Mutation: + @strawberry.mutation + def change(self, attribute: str) -> str: + return attribute + + +@pytest.fixture +def async_app_client_factory(): + def create_app(schema): + async_app = FastAPI() + async_app.include_router(GraphQLRouter(schema), prefix="/graphql") + return TestClient(async_app) + + return create_app + + +@pytest.fixture +def sync_app_client_factory(): + def create_app(schema): + sync_app = Flask(__name__) + sync_app.add_url_rule( + "/graphql", + view_func=GraphQLView.as_view("graphql_view", schema=schema), + ) + return sync_app.test_client() + + return create_app + + +def test_async_execution_uses_async_extension(sentry_init): + sentry_init(integrations=[StrawberryIntegration(async_execution=True)]) + + with mock.patch( + "sentry_sdk.integrations.strawberry._get_installed_modules", + return_value={"flask": "2.3.3"}, + ): + # actual installed modules should not matter, the explicit option takes + # precedence + schema = strawberry.Schema(Query) + assert SentryAsyncExtension in schema.extensions + + +def test_sync_execution_uses_sync_extension(sentry_init): + sentry_init(integrations=[StrawberryIntegration(async_execution=False)]) + + with mock.patch( + "sentry_sdk.integrations.strawberry._get_installed_modules", + return_value={"fastapi": "0.103.1", "starlette": "0.27.0"}, + ): + # actual installed modules should not matter, the explicit option takes + # precedence + schema = strawberry.Schema(Query) + assert SentrySyncExtension in schema.extensions + + +def test_infer_execution_type_from_installed_packages_async(sentry_init): + sentry_init(integrations=[StrawberryIntegration()]) + + with mock.patch( + "sentry_sdk.integrations.strawberry._get_installed_modules", + return_value={"fastapi": "0.103.1", "starlette": "0.27.0"}, + ): + schema = strawberry.Schema(Query) + assert SentryAsyncExtension in schema.extensions + + +def test_infer_execution_type_from_installed_packages_sync(sentry_init): + sentry_init(integrations=[StrawberryIntegration()]) + + with mock.patch( + "sentry_sdk.integrations.strawberry._get_installed_modules", + return_value={"flask": "2.3.3"}, + ): + schema = strawberry.Schema(Query) + assert SentrySyncExtension in schema.extensions + + +def test_replace_existing_sentry_async_extension(sentry_init): + sentry_init(integrations=[StrawberryIntegration()]) + + schema = strawberry.Schema(Query, extensions=[SentryTracingExtension]) + assert SentryTracingExtension not in schema.extensions + assert SentrySyncExtension not in schema.extensions + assert SentryAsyncExtension in schema.extensions + + +def test_replace_existing_sentry_sync_extension(sentry_init): + sentry_init(integrations=[StrawberryIntegration()]) + + schema = strawberry.Schema(Query, extensions=[SentryTracingExtensionSync]) + assert SentryTracingExtensionSync not in schema.extensions + assert SentryAsyncExtension not in schema.extensions + assert SentrySyncExtension in schema.extensions + + +@parameterize_strawberry_test +def test_capture_request_if_available_and_send_pii_is_on( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + send_default_pii=True, + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "query ErrorQuery { error }" + client.post("/graphql", json={"query": query, "operationName": "ErrorQuery"}) + + assert len(events) == 1 + + (error_event,) = events + + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry" + assert error_event["request"]["api_target"] == "graphql" + assert error_event["request"]["data"] == { + "query": query, + "operationName": "ErrorQuery", + } + assert error_event["contexts"]["response"] == { + "data": { + "data": None, + "errors": [ + { + "message": "division by zero", + "locations": [{"line": 1, "column": 20}], + "path": ["error"], + } + ], + } + } + assert len(error_event["breadcrumbs"]["values"]) == 1 + assert error_event["breadcrumbs"]["values"][0]["category"] == "graphql.operation" + assert error_event["breadcrumbs"]["values"][0]["data"] == { + "operation_name": "ErrorQuery", + "operation_type": "query", + } + + +@parameterize_strawberry_test +def test_do_not_capture_request_if_send_pii_is_off( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "query ErrorQuery { error }" + client.post("/graphql", json={"query": query, "operationName": "ErrorQuery"}) + + assert len(events) == 1 + + (error_event,) = events + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry" + assert "data" not in error_event["request"] + assert "response" not in error_event["contexts"] + + assert len(error_event["breadcrumbs"]["values"]) == 1 + assert error_event["breadcrumbs"]["values"][0]["category"] == "graphql.operation" + assert error_event["breadcrumbs"]["values"][0]["data"] == { + "operation_name": "ErrorQuery", + "operation_type": "query", + } + + +@parameterize_strawberry_test +def test_breadcrumb_no_operation_name( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "{ error }" + client.post("/graphql", json={"query": query}) + + assert len(events) == 1 + + (error_event,) = events + + assert len(error_event["breadcrumbs"]["values"]) == 1 + assert error_event["breadcrumbs"]["values"][0]["category"] == "graphql.operation" + assert error_event["breadcrumbs"]["values"][0]["data"] == { + "operation_name": None, + "operation_type": "query", + } + + +@parameterize_strawberry_test +def test_capture_transaction_on_error( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + send_default_pii=True, + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + traces_sample_rate=1, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "query ErrorQuery { error }" + client.post("/graphql", json={"query": query, "operationName": "ErrorQuery"}) + + assert len(events) == 2 + (_, transaction_event) = events + + if async_execution: + assert transaction_event["transaction"] == "/graphql" + else: + assert transaction_event["transaction"] == "graphql_view" + + assert transaction_event["spans"] + + query_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["description"] == "query ErrorQuery" + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] == "ErrorQuery" + assert query_span["data"]["graphql.document"] == query + assert query_span["data"]["graphql.resource_name"] + + parse_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Query.error" + assert resolve_span["data"] == { + "graphql.field_name": "error", + "graphql.parent_type": "Query", + "graphql.field_path": "Query.error", + "graphql.path": "error", + } + + +@parameterize_strawberry_test +def test_capture_transaction_on_success( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + traces_sample_rate=1, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "query GreetingQuery { hello }" + client.post("/graphql", json={"query": query, "operationName": "GreetingQuery"}) + + assert len(events) == 1 + (transaction_event,) = events + + if async_execution: + assert transaction_event["transaction"] == "/graphql" + else: + assert transaction_event["transaction"] == "graphql_view" + + assert transaction_event["spans"] + + query_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["description"] == "query GreetingQuery" + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] == "GreetingQuery" + assert query_span["data"]["graphql.document"] == query + assert query_span["data"]["graphql.resource_name"] + + parse_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Query.hello" + assert resolve_span["data"] == { + "graphql.field_name": "hello", + "graphql.parent_type": "Query", + "graphql.field_path": "Query.hello", + "graphql.path": "hello", + } + + +@parameterize_strawberry_test +def test_transaction_no_operation_name( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + traces_sample_rate=1, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "{ hello }" + client.post("/graphql", json={"query": query}) + + assert len(events) == 1 + (transaction_event,) = events + + if async_execution: + assert transaction_event["transaction"] == "/graphql" + else: + assert transaction_event["transaction"] == "graphql_view" + + assert transaction_event["spans"] + + query_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["description"] == "query" + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] is None + assert query_span["data"]["graphql.document"] == query + assert query_span["data"]["graphql.resource_name"] + + parse_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Query.hello" + assert resolve_span["data"] == { + "graphql.field_name": "hello", + "graphql.parent_type": "Query", + "graphql.field_path": "Query.hello", + "graphql.path": "hello", + } + + +@parameterize_strawberry_test +def test_transaction_mutation( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + traces_sample_rate=1, + ) + events = capture_events() + + schema = strawberry.Schema(Query, mutation=Mutation) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = 'mutation Change { change(attribute: "something") }' + client.post("/graphql", json={"query": query}) + + assert len(events) == 1 + (transaction_event,) = events + + if async_execution: + assert transaction_event["transaction"] == "/graphql" + else: + assert transaction_event["transaction"] == "graphql_view" + + assert transaction_event["spans"] + + query_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_MUTATION + ] + assert len(query_spans) == 1, "exactly one mutation span expected" + query_span = query_spans[0] + assert query_span["description"] == "mutation" + assert query_span["data"]["graphql.operation.type"] == "mutation" + assert query_span["data"]["graphql.operation.name"] is None + assert query_span["data"]["graphql.document"] == query + assert query_span["data"]["graphql.resource_name"] + + parse_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_PARSE + ] + assert len(parse_spans) == 1, "exactly one parse span expected" + parse_span = parse_spans[0] + assert parse_span["parent_span_id"] == query_span["span_id"] + assert parse_span["description"] == "parsing" + + validate_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_VALIDATE + ] + assert len(validate_spans) == 1, "exactly one validate span expected" + validate_span = validate_spans[0] + assert validate_span["parent_span_id"] == query_span["span_id"] + assert validate_span["description"] == "validation" + + resolve_spans = [ + span for span in transaction_event["spans"] if span["op"] == OP.GRAPHQL_RESOLVE + ] + assert len(resolve_spans) == 1, "exactly one resolve span expected" + resolve_span = resolve_spans[0] + assert resolve_span["parent_span_id"] == query_span["span_id"] + assert resolve_span["description"] == "resolving Mutation.change" + assert resolve_span["data"] == { + "graphql.field_name": "change", + "graphql.parent_type": "Mutation", + "graphql.field_path": "Mutation.change", + "graphql.path": "change", + } diff --git a/tox.ini b/tox.ini index ef3289fbfa..f76c3f3876 100644 --- a/tox.ini +++ b/tox.ini @@ -166,6 +166,9 @@ envlist = {py2.7,py3.7,py3.8,py3.9,py3.10,py3.11}-sqlalchemy-v{1.2,1.3,1.4} {py3.7,py3.8,py3.9,py3.10,py3.11}-sqlalchemy-v{2.0} + # Strawberry + {py3.8,py3.9,py3.10,py3.11}-strawberry + # Tornado {py3.7,py3.8,py3.9}-tornado-v{5} {py3.7,py3.8,py3.9,py3.10,py3.11}-tornado-v{6} @@ -484,6 +487,12 @@ deps = sqlalchemy-v1.4: sqlalchemy>=1.4,<2.0 sqlalchemy-v2.0: sqlalchemy>=2.0,<2.1 + # Strawberry + strawberry: strawberry-graphql[fastapi,flask] + strawberry: fastapi + strawberry: flask + strawberry: httpx + # Tornado tornado-v5: tornado>=5,<6 tornado-v6: tornado>=6.0a1 @@ -537,6 +546,7 @@ setenv = starlette: TESTPATH=tests/integrations/starlette starlite: TESTPATH=tests/integrations/starlite sqlalchemy: TESTPATH=tests/integrations/sqlalchemy + strawberry: TESTPATH=tests/integrations/strawberry tornado: TESTPATH=tests/integrations/tornado trytond: TESTPATH=tests/integrations/trytond socket: TESTPATH=tests/integrations/socket