From 1bdf3b1e683a885be7fd2468587221d907d2223f Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Thu, 24 Mar 2022 14:38:59 -0700 Subject: [PATCH 01/26] Add GraphQL Server Sanic Instrumentation --- newrelic/config.py | 6 ++ newrelic/hooks/component_graphqlserver.py | 49 ++++++++++ .../component_graphqlserver/_test_graphql.py | 38 ++++++++ tests/component_graphqlserver/conftest.py | 38 ++++++++ tests/component_graphqlserver/test_graphql.py | 90 +++++++++++++++++++ tests/framework_starlette/test_graphql.py | 2 +- tox.ini | 7 +- 7 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 newrelic/hooks/component_graphqlserver.py create mode 100644 tests/component_graphqlserver/_test_graphql.py create mode 100644 tests/component_graphqlserver/conftest.py create mode 100644 tests/component_graphqlserver/test_graphql.py diff --git a/newrelic/config.py b/newrelic/config.py index 1a90ae0a34..ca2e196576 100644 --- a/newrelic/config.py +++ b/newrelic/config.py @@ -2153,6 +2153,12 @@ def _process_module_builtin_defaults(): "instrument_flask_rest", ) + _process_module_definition( + "graphql_server", + "newrelic.hooks.component_graphqlserver", + "instrument_graphqlserver", + ) + # _process_module_definition('web.application', # 'newrelic.hooks.framework_webpy') # _process_module_definition('web.template', diff --git a/newrelic/hooks/component_graphqlserver.py b/newrelic/hooks/component_graphqlserver.py new file mode 100644 index 0000000000..29004c11fa --- /dev/null +++ b/newrelic/hooks/component_graphqlserver.py @@ -0,0 +1,49 @@ +from newrelic.api.asgi_application import wrap_asgi_application +from newrelic.api.error_trace import ErrorTrace +from newrelic.api.graphql_trace import GraphQLOperationTrace +from newrelic.api.transaction import current_transaction +from newrelic.api.transaction_name import TransactionNameWrapper +from newrelic.common.object_names import callable_name +from newrelic.common.object_wrapper import wrap_function_wrapper +from newrelic.core.graphql_utils import graphql_statement +from newrelic.hooks.framework_graphql import ( + framework_version as graphql_framework_version, +) +from newrelic.hooks.framework_graphql import ignore_graphql_duplicate_exception + +def framework_details(): + import graphql_server + return ("GraphQLServer", getattr(graphql_server, "__version__", None)) + +def bind_query(schema, params, *args, **kwargs): + return getattr(params, "query", None) + + +def wrap_get_response(wrapped, instance, args, kwargs): + transaction = current_transaction() + + if not transaction: + return wrapped(*args, **kwargs) + + try: + query = bind_query(*args, **kwargs) + except TypeError: + return wrapped(*args, **kwargs) + + framework = framework_details() + transaction.add_framework_info(name=framework[0], version=framework[1]) + transaction.add_framework_info(name="GraphQL", version=graphql_framework_version()) + + if hasattr(query, "body"): + query = query.body + + transaction.set_transaction_name(callable_name(wrapped), "GraphQL", priority=10) + + with GraphQLOperationTrace() as trace: + trace.product = "GraphQLServer" + trace.statement = graphql_statement(query) + with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + return wrapped(*args, **kwargs) + +def instrument_graphqlserver(module): + wrap_function_wrapper(module, "get_response", wrap_get_response) diff --git a/tests/component_graphqlserver/_test_graphql.py b/tests/component_graphqlserver/_test_graphql.py new file mode 100644 index 0000000000..b478cf83e1 --- /dev/null +++ b/tests/component_graphqlserver/_test_graphql.py @@ -0,0 +1,38 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from sanic import Sanic +from graphql_server.sanic import GraphQLView +from testing_support.asgi_testing import AsgiTest + +from graphql import GraphQLObjectType, GraphQLString, GraphQLSchema, GraphQLField + + +def resolve_hello(root, info): + return "Hello!" + +hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) +query = GraphQLObjectType( + name="Query", + fields={ + "hello": hello_field, + }, +) + +app = Sanic(name="SanicGraphQL") +routes = [ + app.add_route(GraphQLView.as_view(schema=GraphQLSchema(query=query)), "/graphql"), +] + +target_application = AsgiTest(app) diff --git a/tests/component_graphqlserver/conftest.py b/tests/component_graphqlserver/conftest.py new file mode 100644 index 0000000000..c2b5f7d926 --- /dev/null +++ b/tests/component_graphqlserver/conftest.py @@ -0,0 +1,38 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from testing_support.fixtures import ( + code_coverage_fixture, + collector_agent_registration_fixture, + collector_available_fixture, +) + +_coverage_source = [ + "newrelic.hooks.component_graphqlserver", +] + +code_coverage = code_coverage_fixture(source=_coverage_source) + +_default_settings = { + "transaction_tracer.explain_threshold": 0.0, + "transaction_tracer.transaction_threshold": 0.0, + "transaction_tracer.stack_trace_threshold": 0.0, + "debug.log_data_collector_payloads": True, + "debug.record_transaction_failure": True, +} + +collector_agent_registration = collector_agent_registration_fixture( + app_name="Python Agent Test (component_graphqlserver)", + default_settings=_default_settings, +) diff --git a/tests/component_graphqlserver/test_graphql.py b/tests/component_graphqlserver/test_graphql.py new file mode 100644 index 0000000000..ee3c5445ba --- /dev/null +++ b/tests/component_graphqlserver/test_graphql.py @@ -0,0 +1,90 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json + +import pytest +from testing_support.fixtures import dt_enabled, validate_transaction_metrics +from testing_support.validators.validate_span_events import validate_span_events +from testing_support.validators.validate_transaction_count import ( + validate_transaction_count, +) + + +@pytest.fixture(scope="session") +def target_application(): + import _test_graphql + + return _test_graphql.target_application + + +@dt_enabled +def test_graphql_metrics_and_attrs(target_application): + from graphql import __version__ as graphql_version + from graphql_server import __version__ as graphql_server_version + from sanic import __version__ as sanic_version + + FRAMEWORK_METRICS = [ + ("Python/Framework/GraphQL/%s" % graphql_version, 1), + ("Python/Framework/GraphQLServer/%s" % graphql_server_version, 1), + ("Python/Framework/Sanic/%s" % sanic_version, 1), + ] + _test_scoped_metrics = [ + ("GraphQL/resolve/GraphQLServer/hello", 1), + ("GraphQL/operation/GraphQLServer/query//hello", 1), + ("Function/graphql_server.sanic.graphqlview:GraphQLView.post", 1), + ] + _test_unscoped_metrics = [ + ("GraphQL/all", 1), + ("GraphQL/GraphQLServer/all", 1), + ("GraphQL/allWeb", 1), + ("GraphQL/GraphQLServer/allWeb", 1), + ] + _test_scoped_metrics + + _expected_query_operation_attributes = { + "graphql.operation.type": "query", + "graphql.operation.name": "", + "graphql.operation.query": "{ hello }", + } + _expected_query_resolver_attributes = { + "graphql.field.name": "hello", + "graphql.field.parentType": "Query", + "graphql.field.path": "hello", + "graphql.field.returnType": "String", + } + + @validate_span_events(exact_agents=_expected_query_operation_attributes) + @validate_span_events(exact_agents=_expected_query_resolver_attributes) + @validate_transaction_metrics( + "query//hello", + "GraphQL", + scoped_metrics=_test_scoped_metrics, + rollup_metrics=_test_unscoped_metrics + FRAMEWORK_METRICS, + ) + def _test(): + response = target_application.make_request( + "POST", "/graphql", body=json.dumps({"query": "{ hello }"}), headers={"Content-Type": "application/json"} + ) + assert response.status == 200 + assert "Hello!" in response.body.decode("utf-8") + + _test() + + +@validate_transaction_count(0) +def test_ignored_introspection_transactions(target_application): + response = target_application.make_request( + "POST", "/graphql", body=json.dumps({"query": "{ __schema { types { name } } }"}), headers={"Content-Type": "application/json"} + ) + assert response.status == 200 diff --git a/tests/framework_starlette/test_graphql.py b/tests/framework_starlette/test_graphql.py index 241371eb1b..f9122d48d5 100644 --- a/tests/framework_starlette/test_graphql.py +++ b/tests/framework_starlette/test_graphql.py @@ -1,4 +1,4 @@ -# Copyright 2010 New Relic, Inc. + Copyright 2010 New Relic, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tox.ini b/tox.ini index 62970b6c75..81d38e1730 100644 --- a/tox.ini +++ b/tox.ini @@ -60,6 +60,7 @@ envlist = python-component_djangorestframework-py27-djangorestframework0300, python-component_djangorestframework-{py36,py37,py38,py39,py310}-djangorestframeworklatest, python-component_flask_rest-{py27,py36,py37,py38,py39,pypy,pypy3}, + python-component_graphqlserver-{py36,py37,py38,py39,py310}, python-component_tastypie-{py27,pypy}-tastypie0143, python-component_tastypie-{py36,py37,py38,py39,pypy3}-tastypie{0143,latest}, python-coroutines_asyncio-{py36,py37,py38,py39,py310,pypy3}, @@ -147,7 +148,7 @@ envlist = usefixtures = collector_available_fixture collector_agent_registration - code_coverage + ; code_coverage [testenv] deps = @@ -182,6 +183,9 @@ deps = component_flask_rest: flask-restful component_flask_rest: flask-restplus component_flask_rest: flask-restx + component_graphqlserver: graphql-server[sanic]==3.0.0b5 + component_graphqlserver: sanic>20 + component_graphqlserver: jinja2 component_tastypie-tastypie0143: django-tastypie<0.14.4 component_tastypie-{py27,pypy}-tastypie0143: django<1.12 component_tastypie-{py36,py37,py38,py39,pypy3}-tastypie0143: django<3.0.1 @@ -359,6 +363,7 @@ changedir = application_gearman: tests/application_gearman component_djangorestframework: tests/component_djangorestframework component_flask_rest: tests/component_flask_rest + component_graphqlserver: tests/component_graphqlserver component_tastypie: tests/component_tastypie coroutines_asyncio: tests/coroutines_asyncio cross_agent: tests/cross_agent From 9e526d10560e6f4bb61787dba46536bed9e6fcaf Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Thu, 24 Mar 2022 14:56:09 -0700 Subject: [PATCH 02/26] Co-authored-by: Timothy Pansino Co-authored-by: Uma Annamalai From 9f8856b212136704a983bd8678c0988e96208269 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Thu, 24 Mar 2022 15:20:30 -0700 Subject: [PATCH 03/26] Add co-authors Co-authored-by: Timothy Pansino Co-authored-by: Uma Annamalai From 375bda5cca1b6fbae1f2b5451403f40f7db70055 Mon Sep 17 00:00:00 2001 From: Lalleh Rafeei Date: Thu, 24 Mar 2022 15:26:07 -0700 Subject: [PATCH 04/26] Comment out Copyright notice message Co-authored-by: Timothy Pansino Co-authored-by: Uma Annamalai --- tests/framework_starlette/test_graphql.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/framework_starlette/test_graphql.py b/tests/framework_starlette/test_graphql.py index f9122d48d5..241371eb1b 100644 --- a/tests/framework_starlette/test_graphql.py +++ b/tests/framework_starlette/test_graphql.py @@ -1,4 +1,4 @@ - Copyright 2010 New Relic, Inc. +# Copyright 2010 New Relic, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From 07ec74f7140089a4dbe37a09116bbd0f4c3dca32 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Mon, 28 Mar 2022 11:50:31 -0700 Subject: [PATCH 05/26] Finalize Sanic testing --- .../component_graphqlserver/_test_graphql.py | 24 +- tests/component_graphqlserver/test_graphql.py | 390 +++++++++++++++++- 2 files changed, 374 insertions(+), 40 deletions(-) diff --git a/tests/component_graphqlserver/_test_graphql.py b/tests/component_graphqlserver/_test_graphql.py index b478cf83e1..c98d587c17 100644 --- a/tests/component_graphqlserver/_test_graphql.py +++ b/tests/component_graphqlserver/_test_graphql.py @@ -13,26 +13,14 @@ # limitations under the License. from sanic import Sanic -from graphql_server.sanic import GraphQLView -from testing_support.asgi_testing import AsgiTest - -from graphql import GraphQLObjectType, GraphQLString, GraphQLSchema, GraphQLField - -def resolve_hello(root, info): - return "Hello!" +from testing_support.asgi_testing import AsgiTest +from framework_graphql._target_application import _target_application as schema +from graphql_server.sanic import GraphQLView -hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) -query = GraphQLObjectType( - name="Query", - fields={ - "hello": hello_field, - }, -) -app = Sanic(name="SanicGraphQL") +sanic_app = Sanic(name="SanicGraphQL") routes = [ - app.add_route(GraphQLView.as_view(schema=GraphQLSchema(query=query)), "/graphql"), + sanic_app.add_route(GraphQLView.as_view(schema=schema), "/graphql"), ] - -target_application = AsgiTest(app) +target_application = AsgiTest(sanic_app) diff --git a/tests/component_graphqlserver/test_graphql.py b/tests/component_graphqlserver/test_graphql.py index ee3c5445ba..a10f1ab18b 100644 --- a/tests/component_graphqlserver/test_graphql.py +++ b/tests/component_graphqlserver/test_graphql.py @@ -13,14 +13,28 @@ # limitations under the License. import json - import pytest -from testing_support.fixtures import dt_enabled, validate_transaction_metrics +from testing_support.fixtures import ( + dt_enabled, + validate_transaction_errors, + validate_transaction_metrics, +) from testing_support.validators.validate_span_events import validate_span_events from testing_support.validators.validate_transaction_count import ( validate_transaction_count, ) +from newrelic.api.background_task import background_task +from newrelic.common.object_names import callable_name + + +@pytest.fixture(scope="session") +def is_graphql_2(): + from graphql import __version__ as version + + major_version = int(version.split(".")[0]) + return major_version == 2 + @pytest.fixture(scope="session") def target_application(): @@ -29,8 +43,66 @@ def target_application(): return _test_graphql.target_application +@pytest.fixture(scope="session") +def graphql_run(): + """Wrapper function to simulate framework_graphql test behavior.""" + + def execute(target_application, query): + response = target_application.make_request( + "POST", "/graphql", body=json.dumps({"query": query}), headers={"Content-Type": "application/json"} + ) + + if not isinstance(query, str) or "error" not in query: + assert response.status == 200 + + return response + + return execute + + +def example_middleware(next, root, info, **args): #pylint: disable=W0622 + return_value = next(root, info, **args) + return return_value + + +def error_middleware(next, root, info, **args): #pylint: disable=W0622 + raise RuntimeError("Runtime Error!") + + +_runtime_error_name = callable_name(RuntimeError) +_test_runtime_error = [(_runtime_error_name, "Runtime Error!")] +_graphql_base_rollup_metrics = [ + ("GraphQL/all", 1), + ("GraphQL/allWeb", 1), + ("GraphQL/GraphQLServer/all", 1), + ("GraphQL/GraphQLServer/allWeb", 1), +] + + +def test_basic(target_application, graphql_run): + from graphql import __version__ as graphql_version + from graphql_server import __version__ as graphql_server_version + from sanic import __version__ as sanic_version + + FRAMEWORK_METRICS = [ + ("Python/Framework/GraphQL/%s" % graphql_version, 1), + ("Python/Framework/GraphQLServer/%s" % graphql_server_version, 1), + ("Python/Framework/Sanic/%s" % sanic_version, 1), + ] + + @validate_transaction_metrics( + "query//hello", + "GraphQL", + rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, + ) + def _test(): + response = graphql_run(target_application, "{ hello }") + + _test() + + @dt_enabled -def test_graphql_metrics_and_attrs(target_application): +def test_query_and_mutation(target_application, graphql_run): from graphql import __version__ as graphql_version from graphql_server import __version__ as graphql_server_version from sanic import __version__ as sanic_version @@ -40,51 +112,325 @@ def test_graphql_metrics_and_attrs(target_application): ("Python/Framework/GraphQLServer/%s" % graphql_server_version, 1), ("Python/Framework/Sanic/%s" % sanic_version, 1), ] - _test_scoped_metrics = [ - ("GraphQL/resolve/GraphQLServer/hello", 1), - ("GraphQL/operation/GraphQLServer/query//hello", 1), + _test_query_scoped_metrics = [ + ("GraphQL/resolve/GraphQLServer/storage", 1), + ("GraphQL/operation/GraphQLServer/query//storage", 1), ("Function/graphql_server.sanic.graphqlview:GraphQLView.post", 1), ] - _test_unscoped_metrics = [ + _test_query_unscoped_metrics = [ ("GraphQL/all", 1), ("GraphQL/GraphQLServer/all", 1), ("GraphQL/allWeb", 1), ("GraphQL/GraphQLServer/allWeb", 1), - ] + _test_scoped_metrics + ] + _test_query_scoped_metrics + _test_mutation_scoped_metrics = [ + ("GraphQL/resolve/GraphQLServer/storage_add", 1), + ("GraphQL/operation/GraphQLServer/mutation//storage_add", 1), + ("Function/graphql_server.sanic.graphqlview:GraphQLView.post", 1), + ] + _test_mutation_unscoped_metrics = [ + ("GraphQL/all", 1), + ("GraphQL/GraphQLServer/all", 1), + ("GraphQL/allWeb", 1), + ("GraphQL/GraphQLServer/allWeb", 1), + ] + _test_mutation_scoped_metrics + + _expected_mutation_operation_attributes = { + "graphql.operation.type": "mutation", + "graphql.operation.name": "", + "graphql.operation.query": 'mutation { storage_add(string: ?) }', + } + _expected_mutation_resolver_attributes = { + "graphql.field.name": "storage_add", + "graphql.field.parentType": "Mutation", + "graphql.field.path": "storage_add", + "graphql.field.returnType": "String", + } _expected_query_operation_attributes = { "graphql.operation.type": "query", "graphql.operation.name": "", - "graphql.operation.query": "{ hello }", + "graphql.operation.query": "query { storage }", } _expected_query_resolver_attributes = { + "graphql.field.name": "storage", + "graphql.field.parentType": "Query", + "graphql.field.path": "storage", + "graphql.field.returnType": "[String]", + } + + def _test(): + @validate_transaction_metrics( + "mutation//storage_add", + "GraphQL", + scoped_metrics=_test_mutation_scoped_metrics, + rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, + ) + @validate_span_events(exact_agents=_expected_mutation_operation_attributes) + @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) + def _mutation(): + return graphql_run(target_application, 'mutation { storage_add(string: "abc") }') + + @validate_transaction_metrics( + "query//storage", + "GraphQL", + scoped_metrics=_test_query_scoped_metrics, + rollup_metrics=_test_query_unscoped_metrics + FRAMEWORK_METRICS, + ) + @validate_span_events(exact_agents=_expected_query_operation_attributes) + @validate_span_events(exact_agents=_expected_query_resolver_attributes) + def _query(): + return graphql_run(target_application, "query { storage }") + + response = _mutation() + response = _query() + + # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not + assert "storage" in str(response.body.decode("utf-8")) + assert "abc" in str(response.body.decode("utf-8")) + + _test() + + +@pytest.mark.parametrize("field", ("error", "error_non_null")) +@dt_enabled +def test_exception_in_resolver(target_application, graphql_run, field): + query = "query MyQuery { %s }" % field + + txn_name = "framework_graphql._target_application:resolve_error" + + # Metrics + _test_exception_scoped_metrics = [ + ("GraphQL/operation/GraphQLServer/query/MyQuery/%s" % field, 1), + ("GraphQL/resolve/GraphQLServer/%s" % field, 1), + ] + _test_exception_rollup_metrics = [ + ("Errors/all", 1), + ("Errors/allWeb", 1), + ("Errors/WebTransaction/GraphQL/%s" % txn_name, 1), + ] + _test_exception_scoped_metrics + + # Attributes + _expected_exception_resolver_attributes = { + "graphql.field.name": field, + "graphql.field.parentType": "Query", + "graphql.field.path": field, + "graphql.field.returnType": "String!" if "non_null" in field else "String", + } + _expected_exception_operation_attributes = { + "graphql.operation.type": "query", + "graphql.operation.name": "MyQuery", + "graphql.operation.query": query, + } + + @validate_transaction_metrics( + txn_name, + "GraphQL", + scoped_metrics=_test_exception_scoped_metrics, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, + ) + @validate_span_events(exact_agents=_expected_exception_operation_attributes) + @validate_span_events(exact_agents=_expected_exception_resolver_attributes) + @validate_transaction_errors(errors=_test_runtime_error) + def _test(): + response = graphql_run(target_application, query) + + _test() + + +@dt_enabled +@pytest.mark.parametrize( + "query,exc_class", + [ + ("query MyQuery { error_missing_field }", "GraphQLError"), + ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), + ], +) +def test_exception_in_validation(target_application, graphql_run, is_graphql_2, query, exc_class): + if "syntax" in query: + txn_name = "graphql.language.parser:parse" + else: + if is_graphql_2: + txn_name = "graphql.validation.validation:validate" + else: + txn_name = "graphql.validation.validate:validate" + + # Import path differs between versions + if exc_class == "GraphQLError": + from graphql.error import GraphQLError + + exc_class = callable_name(GraphQLError) + + _test_exception_scoped_metrics = [ + ('GraphQL/operation/GraphQLServer///', 1), + ] + _test_exception_rollup_metrics = [ + ("Errors/all", 1), + ("Errors/allWeb", 1), + ("Errors/WebTransaction/GraphQL/%s" % txn_name, 1), + ] + _test_exception_scoped_metrics + + # Attributes + _expected_exception_operation_attributes = { + "graphql.operation.type": "", + "graphql.operation.name": "", + "graphql.operation.query": query, + } + + @validate_transaction_metrics( + txn_name, + "GraphQL", + scoped_metrics=_test_exception_scoped_metrics, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, + ) + @validate_span_events(exact_agents=_expected_exception_operation_attributes) + @validate_transaction_errors(errors=[exc_class]) + def _test(): + response = graphql_run(target_application, query) + + _test() + + +@dt_enabled +def test_operation_metrics_and_attrs(target_application, graphql_run): + operation_metrics = [("GraphQL/operation/GraphQLServer/query/MyQuery/library", 1)] + operation_attrs = { + "graphql.operation.type": "query", + "graphql.operation.name": "MyQuery", + } + + @validate_transaction_metrics( + "query/MyQuery/library", + "GraphQL", + scoped_metrics=operation_metrics, + rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, + ) + # Span count 10: Transaction, View, Operation, and 7 Resolvers + # library, library.name, library.book + # library.book.name and library.book.id for each book resolved (in this case 2) + @validate_span_events(count=10) + @validate_span_events(exact_agents=operation_attrs) + def _test(): + response = graphql_run(target_application, "query MyQuery { library(index: 0) { branch, book { id, name } } }") + + _test() + + +@dt_enabled +def test_field_resolver_metrics_and_attrs(target_application, graphql_run): + field_resolver_metrics = [("GraphQL/resolve/GraphQLServer/hello", 1)] + graphql_attrs = { "graphql.field.name": "hello", "graphql.field.parentType": "Query", "graphql.field.path": "hello", "graphql.field.returnType": "String", } - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) @validate_transaction_metrics( "query//hello", "GraphQL", - scoped_metrics=_test_scoped_metrics, - rollup_metrics=_test_unscoped_metrics + FRAMEWORK_METRICS, + scoped_metrics=field_resolver_metrics, + rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, ) + # Span count 4: Transaction, View, Operation, and 1 Resolver + @validate_span_events(count=4) + @validate_span_events(exact_agents=graphql_attrs) def _test(): - response = target_application.make_request( - "POST", "/graphql", body=json.dumps({"query": "{ hello }"}), headers={"Content-Type": "application/json"} - ) - assert response.status == 200 + response = graphql_run(target_application, "{ hello }") assert "Hello!" in response.body.decode("utf-8") _test() -@validate_transaction_count(0) -def test_ignored_introspection_transactions(target_application): - response = target_application.make_request( - "POST", "/graphql", body=json.dumps({"query": "{ __schema { types { name } } }"}), headers={"Content-Type": "application/json"} +_test_queries = [ + ("{ hello }", "{ hello }"), # Basic query extraction + ("{ error }", "{ error }"), # Extract query on field error + ( + "{ library(index: 0) { branch } }", + "{ library(index: ?) { branch } }", + ), # Integers + ('{ echo(echo: "123") }', "{ echo(echo: ?) }"), # Strings with numerics + ('{ echo(echo: "test") }', "{ echo(echo: ?) }"), # Strings + ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Aliases + ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Variables + ( # Fragments + '{ ...MyFragment } fragment MyFragment on Query { echo(echo: "test") }', + "{ ...MyFragment } fragment MyFragment on Query { echo(echo: ?) }", + ), +] + + +@dt_enabled +@pytest.mark.parametrize("query,obfuscated", _test_queries) +def test_query_obfuscation(target_application, graphql_run, query, obfuscated): + graphql_attrs = {"graphql.operation.query": obfuscated} + + if callable(query): + query = query() + + @validate_span_events(exact_agents=graphql_attrs) + def _test(): + response = graphql_run(target_application, query) + + _test() + + +_test_queries = [ + ("{ hello }", "/hello"), # Basic query + ("{ error }", "/error"), # Extract deepest path on field error + ('{ echo(echo: "test") }', "/echo"), # Fields with arguments + ( + "{ library(index: 0) { branch, book { isbn branch } } }", + "/library", + ), # Complex Example, 1 level + ( + "{ library(index: 0) { book { author { first_name }} } }", + "/library.book.author.first_name", + ), # Complex Example, 2 levels + ("{ library(index: 0) { id, book { name } } }", "/library.book.name"), # Filtering + ('{ TestEcho: echo(echo: "test") }', "/echo"), # Aliases + ( + '{ search(contains: "A") { __typename ... on Book { name } } }', + "/search.name", + ), # InlineFragment + ( + '{ hello echo(echo: "test") }', + "", + ), # Multiple root selections. (need to decide on final behavior) + # FragmentSpread + ( + "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { name id }", # Fragment filtering + "/library.book.name", + ), + ( + "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", + "/library.book.author.first_name", + ), + ( + "{ library(index: 0) { book { ...MyFragment } magazine { ...MagFragment } } } fragment MyFragment on Book { author { first_name } } fragment MagFragment on Magazine { name }", + "/library", + ), +] + + +@dt_enabled +@pytest.mark.parametrize("query,expected_path", _test_queries) +def test_deepest_unique_path(target_application, graphql_run, query, expected_path): + if expected_path == "/error": + txn_name = "framework_graphql._target_application:resolve_error" + else: + txn_name = "query/%s" % expected_path + + @validate_transaction_metrics( + txn_name, + "GraphQL", ) - assert response.status == 200 + def _test(): + response = graphql_run(target_application, query) + + _test() + + +@validate_transaction_count(0) +def test_ignored_introspection_transactions(target_application, graphql_run): + response = graphql_run(target_application, "{ __schema { types { name } } }") From f78142398fa7cd7700d0a77fbc1e0509575efd9e Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Mon, 28 Mar 2022 12:25:22 -0700 Subject: [PATCH 06/26] Fix flask framework details with callable --- newrelic/api/wsgi_application.py | 17 +++++++++++++++-- newrelic/hooks/framework_flask.py | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/newrelic/api/wsgi_application.py b/newrelic/api/wsgi_application.py index 840c094a41..7f010aae7d 100644 --- a/newrelic/api/wsgi_application.py +++ b/newrelic/api/wsgi_application.py @@ -521,8 +521,20 @@ def __iter__(self): def WSGIApplicationWrapper(wrapped, application=None, name=None, group=None, framework=None): - if framework is not None and not isinstance(framework, tuple): - framework = (framework, None) + def get_framework(): + """Used to delay imports by passing framework as a callable.""" + nonlocal framework + + if isinstance(framework, tuple) or framework is None: + return framework + + if callable(framework): + framework = framework() + + if framework is not None and not isinstance(framework, tuple): + framework = (framework, None) + + return framework def _nr_wsgi_application_wrapper_(wrapped, instance, args, kwargs): # Check to see if any transaction is present, even an inactive @@ -530,6 +542,7 @@ def _nr_wsgi_application_wrapper_(wrapped, instance, args, kwargs): # stopped already. transaction = current_transaction(active_only=False) + framework = get_framework() if transaction: # If there is any active transaction we will return without diff --git a/newrelic/hooks/framework_flask.py b/newrelic/hooks/framework_flask.py index 4535b3289a..1b863f9a79 100644 --- a/newrelic/hooks/framework_flask.py +++ b/newrelic/hooks/framework_flask.py @@ -266,7 +266,7 @@ def instrument_flask_views(module): def instrument_flask_app(module): - wrap_wsgi_application(module, "Flask.wsgi_app", framework=framework_details()) + wrap_wsgi_application(module, "Flask.wsgi_app", framework=framework_details) wrap_function_wrapper( module, "Flask.add_url_rule", _nr_wrapper_Flask_add_url_rule_input_ From 58418ee8c03fb7ec596bd28d7e042da9cfc9fb23 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Mon, 28 Mar 2022 13:15:42 -0700 Subject: [PATCH 07/26] Parametrized testing for graphql-server --- .../component_graphqlserver/_test_graphql.py | 57 +++++++++- tests/component_graphqlserver/test_graphql.py | 105 +++++++++--------- tox.ini | 3 +- 3 files changed, 107 insertions(+), 58 deletions(-) diff --git a/tests/component_graphqlserver/_test_graphql.py b/tests/component_graphqlserver/_test_graphql.py index c98d587c17..af952b6fe0 100644 --- a/tests/component_graphqlserver/_test_graphql.py +++ b/tests/component_graphqlserver/_test_graphql.py @@ -12,15 +12,66 @@ # See the License for the specific language governing permissions and # limitations under the License. +from flask import Flask from sanic import Sanic +import json +import webtest from testing_support.asgi_testing import AsgiTest from framework_graphql._target_application import _target_application as schema -from graphql_server.sanic import GraphQLView +from graphql_server.flask import GraphQLView as FlaskView +from graphql_server.sanic import GraphQLView as SanicView +# Sanic +target_application = dict() sanic_app = Sanic(name="SanicGraphQL") routes = [ - sanic_app.add_route(GraphQLView.as_view(schema=schema), "/graphql"), + sanic_app.add_route(SanicView.as_view(schema=schema), "/graphql"), ] -target_application = AsgiTest(sanic_app) +sanic_app = AsgiTest(sanic_app) + +def sanic_execute(query): + response = sanic_app.make_request( + "POST", "/graphql", body=json.dumps({"query": query}), headers={"Content-Type": "application/json"} + ) + body = json.loads(response.body.decode("utf-8")) + + if not isinstance(query, str) or "error" in query: + try: + assert response.status != 200 + except AssertionError: + assert body["errors"] + else: + assert response.status == 200 + assert "errors" not in body or not body["errors"] + + return response + +target_application["Sanic"] = sanic_execute + +# Flask + +flask_app = Flask("FlaskGraphQL") +flask_app.add_url_rule("/graphql", view_func=FlaskView.as_view("graphql", schema=schema)) +flask_app = webtest.TestApp(flask_app) + +def flask_execute(query): + if not isinstance(query, str) or "error" in query: + expect_errors = True + else: + expect_errors = False + + response = flask_app.post( + "/graphql", json.dumps({"query": query}), headers={"Content-Type": "application/json"}, expect_errors=expect_errors + ) + + body = json.loads(response.body.decode("utf-8")) + if expect_errors: + assert body["errors"] + else: + assert "errors" not in body or not body["errors"] + + return response + +target_application["Flask"] = flask_execute diff --git a/tests/component_graphqlserver/test_graphql.py b/tests/component_graphqlserver/test_graphql.py index a10f1ab18b..1faf55abc8 100644 --- a/tests/component_graphqlserver/test_graphql.py +++ b/tests/component_graphqlserver/test_graphql.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json +import importlib import pytest from testing_support.fixtures import ( dt_enabled, @@ -24,7 +24,6 @@ validate_transaction_count, ) -from newrelic.api.background_task import background_task from newrelic.common.object_names import callable_name @@ -35,29 +34,13 @@ def is_graphql_2(): major_version = int(version.split(".")[0]) return major_version == 2 - -@pytest.fixture(scope="session") -def target_application(): +@pytest.fixture(scope="session", params=("Sanic", "Flask")) +def target_application(request): import _test_graphql + framework = request.param + version = importlib.import_module(framework.lower()).__version__ - return _test_graphql.target_application - - -@pytest.fixture(scope="session") -def graphql_run(): - """Wrapper function to simulate framework_graphql test behavior.""" - - def execute(target_application, query): - response = target_application.make_request( - "POST", "/graphql", body=json.dumps({"query": query}), headers={"Content-Type": "application/json"} - ) - - if not isinstance(query, str) or "error" not in query: - assert response.status == 200 - - return response - - return execute + return framework, version, _test_graphql.target_application[framework] def example_middleware(next, root, info, **args): #pylint: disable=W0622 @@ -77,17 +60,18 @@ def error_middleware(next, root, info, **args): #pylint: disable=W0622 ("GraphQL/GraphQLServer/all", 1), ("GraphQL/GraphQLServer/allWeb", 1), ] +_view_metrics = {"Sanic": "Function/graphql_server.sanic.graphqlview:GraphQLView.post", "Flask": "Function/graphql_server.flask.graphqlview:graphql"} -def test_basic(target_application, graphql_run): +def test_basic(target_application): + framework, version, target_application = target_application from graphql import __version__ as graphql_version from graphql_server import __version__ as graphql_server_version - from sanic import __version__ as sanic_version FRAMEWORK_METRICS = [ ("Python/Framework/GraphQL/%s" % graphql_version, 1), ("Python/Framework/GraphQLServer/%s" % graphql_server_version, 1), - ("Python/Framework/Sanic/%s" % sanic_version, 1), + ("Python/Framework/%s/%s" % (framework, version), 1), ] @validate_transaction_metrics( @@ -96,26 +80,26 @@ def test_basic(target_application, graphql_run): rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, ) def _test(): - response = graphql_run(target_application, "{ hello }") + response = target_application("{ hello }") _test() @dt_enabled -def test_query_and_mutation(target_application, graphql_run): +def test_query_and_mutation(target_application): + framework, version, target_application = target_application from graphql import __version__ as graphql_version from graphql_server import __version__ as graphql_server_version - from sanic import __version__ as sanic_version FRAMEWORK_METRICS = [ ("Python/Framework/GraphQL/%s" % graphql_version, 1), ("Python/Framework/GraphQLServer/%s" % graphql_server_version, 1), - ("Python/Framework/Sanic/%s" % sanic_version, 1), + ("Python/Framework/%s/%s" % (framework, version), 1), ] _test_query_scoped_metrics = [ ("GraphQL/resolve/GraphQLServer/storage", 1), ("GraphQL/operation/GraphQLServer/query//storage", 1), - ("Function/graphql_server.sanic.graphqlview:GraphQLView.post", 1), + (_view_metrics[framework], 1), ] _test_query_unscoped_metrics = [ ("GraphQL/all", 1), @@ -127,7 +111,7 @@ def test_query_and_mutation(target_application, graphql_run): _test_mutation_scoped_metrics = [ ("GraphQL/resolve/GraphQLServer/storage_add", 1), ("GraphQL/operation/GraphQLServer/mutation//storage_add", 1), - ("Function/graphql_server.sanic.graphqlview:GraphQLView.post", 1), + (_view_metrics[framework], 1), ] _test_mutation_unscoped_metrics = [ ("GraphQL/all", 1), @@ -169,7 +153,7 @@ def _test(): @validate_span_events(exact_agents=_expected_mutation_operation_attributes) @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) def _mutation(): - return graphql_run(target_application, 'mutation { storage_add(string: "abc") }') + return target_application('mutation { storage_add(string: "abc") }') @validate_transaction_metrics( "query//storage", @@ -180,7 +164,7 @@ def _mutation(): @validate_span_events(exact_agents=_expected_query_operation_attributes) @validate_span_events(exact_agents=_expected_query_resolver_attributes) def _query(): - return graphql_run(target_application, "query { storage }") + return target_application("query { storage }") response = _mutation() response = _query() @@ -194,7 +178,8 @@ def _query(): @pytest.mark.parametrize("field", ("error", "error_non_null")) @dt_enabled -def test_exception_in_resolver(target_application, graphql_run, field): +def test_exception_in_resolver(target_application, field): + framework, version, target_application = target_application query = "query MyQuery { %s }" % field txn_name = "framework_graphql._target_application:resolve_error" @@ -233,7 +218,7 @@ def test_exception_in_resolver(target_application, graphql_run, field): @validate_span_events(exact_agents=_expected_exception_resolver_attributes) @validate_transaction_errors(errors=_test_runtime_error) def _test(): - response = graphql_run(target_application, query) + response = target_application(query) _test() @@ -246,7 +231,8 @@ def _test(): ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), ], ) -def test_exception_in_validation(target_application, graphql_run, is_graphql_2, query, exc_class): +def test_exception_in_validation(target_application, is_graphql_2, query, exc_class): + framework, version, target_application = target_application if "syntax" in query: txn_name = "graphql.language.parser:parse" else: @@ -286,38 +272,43 @@ def test_exception_in_validation(target_application, graphql_run, is_graphql_2, @validate_span_events(exact_agents=_expected_exception_operation_attributes) @validate_transaction_errors(errors=[exc_class]) def _test(): - response = graphql_run(target_application, query) + response = target_application(query) _test() @dt_enabled -def test_operation_metrics_and_attrs(target_application, graphql_run): +def test_operation_metrics_and_attrs(target_application): + framework, version, target_application = target_application operation_metrics = [("GraphQL/operation/GraphQLServer/query/MyQuery/library", 1)] operation_attrs = { "graphql.operation.type": "query", "graphql.operation.name": "MyQuery", } + # Base span count 10: Transaction, View, Operation, and 7 Resolvers + # library, library.name, library.book + # library.book.name and library.book.id for each book resolved (in this case 2) + # For Flask, add 9 more for WSGI and framework related spans + span_count = {"Flask": 19, "Sanic": 10} + @validate_transaction_metrics( "query/MyQuery/library", "GraphQL", scoped_metrics=operation_metrics, rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, ) - # Span count 10: Transaction, View, Operation, and 7 Resolvers - # library, library.name, library.book - # library.book.name and library.book.id for each book resolved (in this case 2) - @validate_span_events(count=10) + @validate_span_events(count=span_count[framework]) @validate_span_events(exact_agents=operation_attrs) def _test(): - response = graphql_run(target_application, "query MyQuery { library(index: 0) { branch, book { id, name } } }") + response = target_application("query MyQuery { library(index: 0) { branch, book { id, name } } }") _test() @dt_enabled -def test_field_resolver_metrics_and_attrs(target_application, graphql_run): +def test_field_resolver_metrics_and_attrs(target_application): + framework, version, target_application = target_application field_resolver_metrics = [("GraphQL/resolve/GraphQLServer/hello", 1)] graphql_attrs = { "graphql.field.name": "hello", @@ -326,17 +317,20 @@ def test_field_resolver_metrics_and_attrs(target_application, graphql_run): "graphql.field.returnType": "String", } + # Base span count 4: Transaction, View, Operation, and 1 Resolver + # For Flask, add 9 more for WSGI and framework related spans + span_count = {"Flask": 13, "Sanic": 4} + @validate_transaction_metrics( "query//hello", "GraphQL", scoped_metrics=field_resolver_metrics, rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, ) - # Span count 4: Transaction, View, Operation, and 1 Resolver - @validate_span_events(count=4) + @validate_span_events(count=span_count[framework]) @validate_span_events(exact_agents=graphql_attrs) def _test(): - response = graphql_run(target_application, "{ hello }") + response = target_application("{ hello }") assert "Hello!" in response.body.decode("utf-8") _test() @@ -362,7 +356,8 @@ def _test(): @dt_enabled @pytest.mark.parametrize("query,obfuscated", _test_queries) -def test_query_obfuscation(target_application, graphql_run, query, obfuscated): +def test_query_obfuscation(target_application, query, obfuscated): + framework, version, target_application = target_application graphql_attrs = {"graphql.operation.query": obfuscated} if callable(query): @@ -370,7 +365,7 @@ def test_query_obfuscation(target_application, graphql_run, query, obfuscated): @validate_span_events(exact_agents=graphql_attrs) def _test(): - response = graphql_run(target_application, query) + response = target_application(query) _test() @@ -415,7 +410,8 @@ def _test(): @dt_enabled @pytest.mark.parametrize("query,expected_path", _test_queries) -def test_deepest_unique_path(target_application, graphql_run, query, expected_path): +def test_deepest_unique_path(target_application, query, expected_path): + framework, version, target_application = target_application if expected_path == "/error": txn_name = "framework_graphql._target_application:resolve_error" else: @@ -426,11 +422,12 @@ def test_deepest_unique_path(target_application, graphql_run, query, expected_pa "GraphQL", ) def _test(): - response = graphql_run(target_application, query) + response = target_application(query) _test() @validate_transaction_count(0) -def test_ignored_introspection_transactions(target_application, graphql_run): - response = graphql_run(target_application, "{ __schema { types { name } } }") +def test_ignored_introspection_transactions(target_application): + framework, version, target_application = target_application + response = target_application("{ __schema { types { name } } }") diff --git a/tox.ini b/tox.ini index 81d38e1730..8806bf5435 100644 --- a/tox.ini +++ b/tox.ini @@ -183,8 +183,9 @@ deps = component_flask_rest: flask-restful component_flask_rest: flask-restplus component_flask_rest: flask-restx - component_graphqlserver: graphql-server[sanic]==3.0.0b5 + component_graphqlserver: graphql-server[sanic,flask]==3.0.0b5 component_graphqlserver: sanic>20 + component_graphqlserver: Flask component_graphqlserver: jinja2 component_tastypie-tastypie0143: django-tastypie<0.14.4 component_tastypie-{py27,pypy}-tastypie0143: django<1.12 From 215538b505ccd90fe2ad11f5390b3687558e54fd Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Mon, 28 Mar 2022 15:07:50 -0700 Subject: [PATCH 08/26] GraphQL Async Resolvers Co-authored-by: Lalleh Rafeei Co-authored-by: Uma Annamalai --- newrelic/api/graphql_trace.py | 8 ++- newrelic/hooks/framework_graphql.py | 53 +++++++++++++++---- .../framework_strawberry/test_application.py | 1 + .../test_application_async.py | 3 +- 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/newrelic/api/graphql_trace.py b/newrelic/api/graphql_trace.py index e14e87f861..72a0492f36 100644 --- a/newrelic/api/graphql_trace.py +++ b/newrelic/api/graphql_trace.py @@ -134,9 +134,12 @@ def wrap_graphql_operation_trace(module, object_path): class GraphQLResolverTrace(TimeTrace): - def __init__(self, field_name=None, **kwargs): + def __init__(self, field_name=None, field_parent_type=None, field_return_type=None, field_path=None, **kwargs): super(GraphQLResolverTrace, self).__init__(**kwargs) self.field_name = field_name + self.field_parent_type = field_parent_type + self.field_return_type = field_return_type + self.field_path = field_path self._product = None def __repr__(self): @@ -164,6 +167,9 @@ def product(self): def finalize_data(self, *args, **kwargs): self._add_agent_attribute("graphql.field.name", self.field_name) + self._add_agent_attribute("graphql.field.parentType", self.field_parent_type) + self._add_agent_attribute("graphql.field.returnType", self.field_return_type) + self._add_agent_attribute("graphql.field.path", self.field_path) return super(GraphQLResolverTrace, self).finalize_data(*args, **kwargs) diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index 83e1fd65bc..222063d9f5 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import time +from inspect import isawaitable import logging from collections import deque @@ -323,7 +325,16 @@ def wrap_resolver(wrapped, instance, args, kwargs): transaction.set_transaction_name(callable_name(wrapped), "GraphQL", priority=13) with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - return wrapped(*args, **kwargs) + result = wrapped(*args, **kwargs) + + if isawaitable(result): + # Grab any async resolvers and wrap with error traces + async def _nr_coro_resolver_error_wrapper(): + with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + return await result + return _nr_coro_resolver_error_wrapper() + + return result def wrap_error_handler(wrapped, instance, args, kwargs): @@ -387,18 +398,38 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): field_name = field_asts[0].name.value field_def = parent_type.fields.get(field_name) field_return_type = str(field_def.type) if field_def else "" + if isinstance(field_path, list): + field_path = field_path[0] + else: + field_path = field_path.key - with GraphQLResolverTrace(field_name) as trace: - with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - trace._add_agent_attribute("graphql.field.parentType", parent_type.name) - trace._add_agent_attribute("graphql.field.returnType", field_return_type) + start_time = time.time() - if isinstance(field_path, list): - trace._add_agent_attribute("graphql.field.path", field_path[0]) - else: - trace._add_agent_attribute("graphql.field.path", field_path.key) - - return wrapped(*args, **kwargs) + try: + result = wrapped(*args, **kwargs) + except Exception: + # Synchonous resolver with exception raised + with GraphQLResolverTrace(field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path) as trace: + trace._start_time = start_time + notice_error(ignore=ignore_graphql_duplicate_exception) + raise + + if isawaitable(result): + # Asynchronous resolvers (returned coroutines from non-coroutine functions) + async def _nr_coro_resolver_wrapper(): + with GraphQLResolverTrace(field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path) as trace: + with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + trace._start_time = start_time + return await result + + # Return a coroutine that handles wrapping in a resolver trace + return _nr_coro_resolver_wrapper() + else: + # Synchonous resolver with no exception raised + with GraphQLResolverTrace(field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path) as trace: + with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + trace._start_time = start_time + return result def bind_graphql_impl_query(schema, source, *args, **kwargs): diff --git a/tests/framework_strawberry/test_application.py b/tests/framework_strawberry/test_application.py index b68b825fff..9f23c13967 100644 --- a/tests/framework_strawberry/test_application.py +++ b/tests/framework_strawberry/test_application.py @@ -105,6 +105,7 @@ def test_basic(app, graphql_run): def _test(): response = graphql_run(app, "{ hello }") assert not response.errors + assert response.data["hello"] == "Hello!" _test() diff --git a/tests/framework_strawberry/test_application_async.py b/tests/framework_strawberry/test_application_async.py index 8174eb36ee..85d109955e 100644 --- a/tests/framework_strawberry/test_application_async.py +++ b/tests/framework_strawberry/test_application_async.py @@ -43,7 +43,7 @@ def execute(schema, *args, **kwargs): loop = asyncio.new_event_loop() -def test_basic(app, graphql_run_async): +def test_basic_async(app, graphql_run_async): from graphql import __version__ as version from newrelic.hooks.framework_strawberry import framework_details @@ -64,6 +64,7 @@ def _test(): async def coro(): response = await graphql_run_async(app, "{ hello_async }") assert not response.errors + assert response.data["hello_async"] == "Hello!" loop.run_until_complete(coro()) From 8cda4f76a86824b3166ac886e552717919f66953 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Wed, 20 Apr 2022 14:48:07 -0700 Subject: [PATCH 09/26] GraphQL Proper Coro and Promise Support (#508) * Fix GraphQL async issues * Fix nonlocal binding issues in python 2 * Fix promises with async graphql * Issues with promises * Fix promises in graphql2 * Fixed all graphql async issues * Fix Py27 quirks * Update tox * Fix importing paths of graphqlserver * Fix broken import path * Unpin pypy37 * Fix weird import issues --- newrelic/api/graphql_trace.py | 2 +- newrelic/api/wsgi_application.py | 5 + newrelic/hooks/framework_graphql.py | 180 +++++++--- newrelic/hooks/framework_graphql_py3.py | 51 +++ .../component_graphqlserver/_test_graphql.py | 11 +- tests/component_graphqlserver/test_graphql.py | 4 +- .../framework_graphql/_target_application.py | 313 +++++------------ .../framework_graphql/_target_schema_async.py | 187 ++++++++++ .../_target_schema_promise.py | 194 +++++++++++ .../framework_graphql/_target_schema_sync.py | 239 +++++++++++++ tests/framework_graphql/conftest.py | 18 +- tests/framework_graphql/test_application.py | 324 ++++++++++-------- .../test_application_async.py | 100 +----- tox.ini | 4 +- 14 files changed, 1121 insertions(+), 511 deletions(-) create mode 100644 newrelic/hooks/framework_graphql_py3.py create mode 100644 tests/framework_graphql/_target_schema_async.py create mode 100644 tests/framework_graphql/_target_schema_promise.py create mode 100644 tests/framework_graphql/_target_schema_sync.py diff --git a/newrelic/api/graphql_trace.py b/newrelic/api/graphql_trace.py index 9b240a0521..6863bd73de 100644 --- a/newrelic/api/graphql_trace.py +++ b/newrelic/api/graphql_trace.py @@ -134,7 +134,7 @@ def wrap_graphql_operation_trace(module, object_path): class GraphQLResolverTrace(TimeTrace): - def __init__(self, field_name=None, **kwargs): + def __init__(self, field_name=None, field_parent_type=None, field_return_type=None, field_path=None, **kwargs): parent = kwargs.pop("parent", None) source = kwargs.pop("source", None) if kwargs: diff --git a/newrelic/api/wsgi_application.py b/newrelic/api/wsgi_application.py index 15a21c5510..0f4d304544 100644 --- a/newrelic/api/wsgi_application.py +++ b/newrelic/api/wsgi_application.py @@ -526,9 +526,14 @@ def get_framework(): framework = framework() _framework[0] = framework + if framework is not None and not isinstance(framework, tuple): + framework = (framework, None) + _framework[0] = framework + return framework def _nr_wsgi_application_wrapper_(wrapped, instance, args, kwargs): + # Check to see if any transaction is present, even an inactive # one which has been marked to be ignored or which has been # stopped already. diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index b640427926..dd2c31f703 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -12,9 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import time -from inspect import isawaitable +import functools import logging +import sys +import time from collections import deque from newrelic.api.error_trace import ErrorTrace @@ -25,6 +26,40 @@ from newrelic.common.object_names import callable_name, parse_exc_info from newrelic.common.object_wrapper import function_wrapper, wrap_function_wrapper from newrelic.core.graphql_utils import graphql_statement +from newrelic.packages import six + +try: + from inspect import isawaitable +except ImportError: + + def isawaitable(f): + return False + + +try: + # from promise import is_thenable as is_promise + from promise import Promise + + def is_promise(obj): + return isinstance(obj, Promise) + + def as_promise(f): + return Promise.resolve(None).then(f) + +except ImportError: + # If promises is not installed, prevent crashes by bypassing logic + def is_promise(obj): + return False + + def as_promise(f): + return f + +if six.PY3: + from newrelic.hooks.framework_graphql_py3 import ( + nr_coro_execute_name_wrapper, + nr_coro_resolver_error_wrapper, + nr_coro_resolver_wrapper, + ) _logger = logging.getLogger(__name__) @@ -79,6 +114,20 @@ def ignore_graphql_duplicate_exception(exc, val, tb): return None # Follow original exception matching rules +def catch_promise_error(e): + if hasattr(e, "__traceback__"): + notice_error(error=(e.__class__, e, e.__traceback__), ignore=ignore_graphql_duplicate_exception) + else: + # Python 2 does not retain a reference to the traceback and is irretrievable from a promise. + # As a workaround, raise the error and report it despite having an incorrect traceback. + try: + raise e + except Exception: + notice_error(ignore=ignore_graphql_duplicate_exception) + + return None + + def wrap_executor_context_init(wrapped, instance, args, kwargs): result = wrapped(*args, **kwargs) @@ -146,12 +195,20 @@ def wrap_execute_operation(wrapped, instance, args, kwargs): transaction.set_transaction_name(callable_name(wrapped), "GraphQL", priority=11) result = wrapped(*args, **kwargs) - if not execution_context.errors: - if hasattr(trace, "set_transaction_name"): + + def set_name(value=None): + if not execution_context.errors and hasattr(trace, "set_transaction_name"): # Operation trace sets transaction name trace.set_transaction_name(priority=14) - - return result + return value + + if is_promise(result) and result.is_pending and graphql_version() < (3, 0): + return result.then(set_name) + elif isawaitable(result) and not is_promise(result): + return nr_coro_execute_name_wrapper(wrapped, result, set_name) + else: + set_name() + return result def get_node_value(field, attr, subattr="value"): @@ -274,7 +331,8 @@ def wrap_middleware(wrapped, instance, args, kwargs): transaction.set_transaction_name(name, "GraphQL", priority=12) with FunctionTrace(name, source=wrapped): with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - return wrapped(*args, **kwargs) + result = wrapped(*args, **kwargs) + return result def bind_get_field_resolver(field_resolver): @@ -326,16 +384,32 @@ def wrap_resolver(wrapped, instance, args, kwargs): transaction.set_transaction_name(name, "GraphQL", priority=13) with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + sync_start_time = time.time() result = wrapped(*args, **kwargs) - - if isawaitable(result): - # Grab any async resolvers and wrap with error traces - async def _nr_coro_resolver_error_wrapper(): - with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - return await result - return _nr_coro_resolver_error_wrapper() - - return result + + if is_promise(result) and result.is_pending and graphql_version() < (3, 0): + @functools.wraps(wrapped) + def nr_promise_resolver_error_wrapper(v): + with FunctionTrace(name, source=wrapped): + with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + try: + return result.get() + except Exception: + transaction.set_transaction_name(name, "GraphQL", priority=15) + raise + return as_promise(nr_promise_resolver_error_wrapper) + elif isawaitable(result) and not is_promise(result): + # Grab any async resolvers and wrap with traces + return nr_coro_resolver_error_wrapper( + wrapped, name, ignore_graphql_duplicate_exception, result, transaction + ) + else: + with FunctionTrace(name, source=wrapped) as trace: + trace.start_time = sync_start_time + if is_promise(result) and result.is_rejected: + result.catch(catch_promise_error).get() + transaction.set_transaction_name(name, "GraphQL", priority=15) + return result def wrap_error_handler(wrapped, instance, args, kwargs): @@ -404,33 +478,36 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): else: field_path = field_path.key + trace = GraphQLResolverTrace( + field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path + ) start_time = time.time() try: result = wrapped(*args, **kwargs) except Exception: # Synchonous resolver with exception raised - with GraphQLResolverTrace(field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path) as trace: - trace._start_time = start_time + with trace: + trace.start_time = start_time notice_error(ignore=ignore_graphql_duplicate_exception) raise - if isawaitable(result): - # Asynchronous resolvers (returned coroutines from non-coroutine functions) - async def _nr_coro_resolver_wrapper(): - with GraphQLResolverTrace(field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path) as trace: + if is_promise(result) and result.is_pending and graphql_version() < (3, 0): + @functools.wraps(wrapped) + def nr_promise_resolver_wrapper(v): + with trace: with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - trace._start_time = start_time - return await result - + return result.get() + return as_promise(nr_promise_resolver_wrapper) + elif isawaitable(result) and not is_promise(result): + # Asynchronous resolvers (returned coroutines from non-coroutine functions) # Return a coroutine that handles wrapping in a resolver trace - return _nr_coro_resolver_wrapper() + return nr_coro_resolver_wrapper(wrapped, trace, ignore_graphql_duplicate_exception, result) else: # Synchonous resolver with no exception raised - with GraphQLResolverTrace(field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path) as trace: - with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - trace._start_time = start_time - return result + with trace: + trace.start_time = start_time + return result def bind_graphql_impl_query(schema, source, *args, **kwargs): @@ -473,17 +550,42 @@ def wrap_graphql_impl(wrapped, instance, args, kwargs): transaction.set_transaction_name(callable_name(wrapped), "GraphQL", priority=10) - with GraphQLOperationTrace() as trace: - trace.statement = graphql_statement(query) + trace = GraphQLOperationTrace() - # Handle Schemas created from frameworks - if hasattr(schema, "_nr_framework"): - framework = schema._nr_framework - trace.product = framework[0] - transaction.add_framework_info(name=framework[0], version=framework[1]) + trace.statement = graphql_statement(query) - with ErrorTrace(ignore=ignore_graphql_duplicate_exception): - result = wrapped(*args, **kwargs) + # Handle Schemas created from frameworks + if hasattr(schema, "_nr_framework"): + framework = schema._nr_framework + trace.product = framework[0] + transaction.add_framework_info(name=framework[0], version=framework[1]) + + # Trace must be manually started and stopped to ensure it exists prior to and during the entire duration of the query. + # Otherwise subsequent instrumentation will not be able to find an operation trace and will have issues. + trace.__enter__() + try: + result = wrapped(*args, **kwargs) + except Exception as e: + # Execution finished synchronously, exit immediately. + notice_error(ignore=ignore_graphql_duplicate_exception) + trace.__exit__(*sys.exc_info()) + raise + else: + if is_promise(result) and result.is_pending: + # Execution promise, append callbacks to exit trace. + def on_resolve(v): + trace.__exit__(None, None, None) + return v + + def on_reject(e): + catch_promise_error(e) + trace.__exit__(e.__class__, e, e.__traceback__) + return e + + return result.then(on_resolve, on_reject) + else: + # Execution finished synchronously, exit immediately. + trace.__exit__(None, None, None) return result diff --git a/newrelic/hooks/framework_graphql_py3.py b/newrelic/hooks/framework_graphql_py3.py new file mode 100644 index 0000000000..d46fb59886 --- /dev/null +++ b/newrelic/hooks/framework_graphql_py3.py @@ -0,0 +1,51 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import functools + +from newrelic.api.error_trace import ErrorTrace +from newrelic.api.function_trace import FunctionTrace + + +def nr_coro_execute_name_wrapper(wrapped, result, set_name): + @functools.wraps(wrapped) + async def _nr_coro_execute_name_wrapper(): + result_ = await result + set_name() + return result_ + + return _nr_coro_execute_name_wrapper() + + +def nr_coro_resolver_error_wrapper(wrapped, name, ignore, result, transaction): + @functools.wraps(wrapped) + async def _nr_coro_resolver_error_wrapper(): + with FunctionTrace(name, source=wrapped): + with ErrorTrace(ignore=ignore): + try: + return await result + except Exception: + transaction.set_transaction_name(name, "GraphQL", priority=15) + raise + + return _nr_coro_resolver_error_wrapper() + + +def nr_coro_resolver_wrapper(wrapped, trace, ignore, result): + @functools.wraps(wrapped) + async def _nr_coro_resolver_wrapper(): + with trace: + with ErrorTrace(ignore=ignore): + return await result + + return _nr_coro_resolver_wrapper() diff --git a/tests/component_graphqlserver/_test_graphql.py b/tests/component_graphqlserver/_test_graphql.py index db2bc696cc..7a29b3a8fe 100644 --- a/tests/component_graphqlserver/_test_graphql.py +++ b/tests/component_graphqlserver/_test_graphql.py @@ -12,16 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json - -import webtest from flask import Flask -from framework_graphql._target_application import _target_application as schema -from graphql_server.flask import GraphQLView as FlaskView -from graphql_server.sanic import GraphQLView as SanicView from sanic import Sanic +import json +import webtest + from testing_support.asgi_testing import AsgiTest -from framework_graphql._target_application import _target_application as schema +from framework_graphql._target_schema_sync import target_schema as schema from graphql_server.flask import GraphQLView as FlaskView from graphql_server.sanic import GraphQLView as SanicView diff --git a/tests/component_graphqlserver/test_graphql.py b/tests/component_graphqlserver/test_graphql.py index 62d96794b7..f361e2193a 100644 --- a/tests/component_graphqlserver/test_graphql.py +++ b/tests/component_graphqlserver/test_graphql.py @@ -287,7 +287,7 @@ def test_exception_in_resolver(target_application, field): framework, version, target_application = target_application query = "query MyQuery { %s }" % field - txn_name = "framework_graphql._target_application:resolve_error" + txn_name = "framework_graphql._target_schema_sync:resolve_error" # Metrics _test_exception_scoped_metrics = [ @@ -518,7 +518,7 @@ def _test(): def test_deepest_unique_path(target_application, query, expected_path): framework, version, target_application = target_application if expected_path == "/error": - txn_name = "framework_graphql._target_application:resolve_error" + txn_name = "framework_graphql._target_schema_sync:resolve_error" else: txn_name = "query/%s" % expected_path diff --git a/tests/framework_graphql/_target_application.py b/tests/framework_graphql/_target_application.py index 7bef5e9754..903c72137e 100644 --- a/tests/framework_graphql/_target_application.py +++ b/tests/framework_graphql/_target_application.py @@ -12,228 +12,91 @@ # See the License for the specific language governing permissions and # limitations under the License. -from graphql import ( - GraphQLArgument, - GraphQLField, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLObjectType, - GraphQLSchema, - GraphQLString, - GraphQLUnionType, -) - -authors = [ - { - "first_name": "New", - "last_name": "Relic", - }, - { - "first_name": "Bob", - "last_name": "Smith", - }, - { - "first_name": "Leslie", - "last_name": "Jones", - }, -] - -books = [ - { - "id": 1, - "name": "Python Agent: The Book", - "isbn": "a-fake-isbn", - "author": authors[0], - "branch": "riverside", - }, - { - "id": 2, - "name": "Ollies for O11y: A Sk8er's Guide to Observability", - "isbn": "a-second-fake-isbn", - "author": authors[1], - "branch": "downtown", - }, - { - "id": 3, - "name": "[Redacted]", - "isbn": "a-third-fake-isbn", - "author": authors[2], - "branch": "riverside", - }, -] - -magazines = [ - {"id": 1, "name": "Reli Updates Weekly", "issue": 1, "branch": "riverside"}, - {"id": 2, "name": "Reli Updates Weekly", "issue": 2, "branch": "downtown"}, - {"id": 3, "name": "Node Weekly", "issue": 1, "branch": "riverside"}, -] - - -libraries = ["riverside", "downtown"] -libraries = [ - { - "id": i + 1, - "branch": branch, - "magazine": [m for m in magazines if m["branch"] == branch], - "book": [b for b in books if b["branch"] == branch], - } - for i, branch in enumerate(libraries) -] - -storage = [] - - -def resolve_library(parent, info, index): - return libraries[index] - - -def resolve_storage_add(parent, info, string): - storage.append(string) - return string - - -def resolve_storage(parent, info): - return storage - - -def resolve_search(parent, info, contains): - search_books = [b for b in books if contains in b["name"]] - search_magazines = [m for m in magazines if contains in m["name"]] - return search_books + search_magazines - - -Author = GraphQLObjectType( - "Author", - { - "first_name": GraphQLField(GraphQLString), - "last_name": GraphQLField(GraphQLString), - }, -) - -Book = GraphQLObjectType( - "Book", - { - "id": GraphQLField(GraphQLInt), - "name": GraphQLField(GraphQLString), - "isbn": GraphQLField(GraphQLString), - "author": GraphQLField(Author), - "branch": GraphQLField(GraphQLString), - }, -) - -Magazine = GraphQLObjectType( - "Magazine", - { - "id": GraphQLField(GraphQLInt), - "name": GraphQLField(GraphQLString), - "issue": GraphQLField(GraphQLInt), - "branch": GraphQLField(GraphQLString), - }, -) - - -Library = GraphQLObjectType( - "Library", - { - "id": GraphQLField(GraphQLInt), - "branch": GraphQLField(GraphQLString), - "book": GraphQLField(GraphQLList(Book)), - "magazine": GraphQLField(GraphQLList(Magazine)), - }, -) - -Storage = GraphQLList(GraphQLString) - - -def resolve_hello(root, info): - return "Hello!" - - -def resolve_echo(root, info, echo): - return echo - - -def resolve_error(root, info): - raise RuntimeError("Runtime Error!") - - -try: - hello_field = GraphQLField(GraphQLString, resolver=resolve_hello) - library_field = GraphQLField( - Library, - resolver=resolve_library, - args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, - ) - search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), - args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, - ) - echo_field = GraphQLField( - GraphQLString, - resolver=resolve_echo, - args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, - ) - storage_field = GraphQLField( - Storage, - resolver=resolve_storage, - ) - storage_add_field = GraphQLField( - Storage, - resolver=resolve_storage_add, - args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, - ) - error_field = GraphQLField(GraphQLString, resolver=resolve_error) - error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolver=resolve_error) - error_middleware_field = GraphQLField(GraphQLString, resolver=resolve_hello) -except TypeError: - hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) - library_field = GraphQLField( - Library, - resolve=resolve_library, - args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, - ) - search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), - args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, - ) - echo_field = GraphQLField( - GraphQLString, - resolve=resolve_echo, - args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, - ) - storage_field = GraphQLField( - Storage, - resolve=resolve_storage, - ) - storage_add_field = GraphQLField( - GraphQLString, - resolve=resolve_storage_add, - args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, - ) - error_field = GraphQLField(GraphQLString, resolve=resolve_error) - error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_error) - error_middleware_field = GraphQLField(GraphQLString, resolve=resolve_hello) - -query = GraphQLObjectType( - name="Query", - fields={ - "hello": hello_field, - "library": library_field, - "search": search_field, - "echo": echo_field, - "storage": storage_field, - "error": error_field, - "error_non_null": error_non_null_field, - "error_middleware": error_middleware_field, - }, -) - -mutation = GraphQLObjectType( - name="Mutation", - fields={ - "storage_add": storage_add_field, - }, -) - -_target_application = GraphQLSchema(query=query, mutation=mutation) +from graphql import __version__ as version +from graphql.language.source import Source + +from newrelic.packages import six +from newrelic.hooks.framework_graphql import is_promise + +from _target_schema_sync import target_schema as target_schema_sync + + +is_graphql_2 = int(version.split(".")[0]) == 2 + + +def check_response(query, response): + if isinstance(query, str) and "error" not in query or isinstance(query, Source) and "error" not in query.body: + assert not response.errors, response.errors + assert response.data + else: + assert response.errors + + +def run_sync(schema): + def _run_sync(query, middleware=None): + try: + from graphql import graphql_sync as graphql + except ImportError: + from graphql import graphql + + response = graphql(schema, query, middleware=middleware) + + check_response(query, response) + + return response.data + + return _run_sync + + +def run_async(schema): + import asyncio + from graphql import graphql + + def _run_async(query, middleware=None): + coro = graphql(schema, query, middleware=middleware) + loop = asyncio.get_event_loop() + response = loop.run_until_complete(coro) + + check_response(query, response) + + return response.data + + return _run_async + + +def run_promise(schema, scheduler): + from graphql import graphql + from promise import set_default_scheduler + + def _run_promise(query, middleware=None): + set_default_scheduler(scheduler) + + promise = graphql(schema, query, middleware=middleware, return_promise=True) + response = promise.get() + + check_response(query, response) + + return response.data + + return _run_promise + + +target_application = { + "sync-sync": run_sync(target_schema_sync), +} + +if is_graphql_2: + from _target_schema_promise import target_schema as target_schema_promise + from promise.schedulers.immediate import ImmediateScheduler + + if six.PY3: + from promise.schedulers.asyncio import AsyncioScheduler as AsyncScheduler + else: + from promise.schedulers.thread import ThreadScheduler as AsyncScheduler + + target_application["sync-promise"] = run_promise(target_schema_promise, ImmediateScheduler()) + target_application["async-promise"] = run_promise(target_schema_promise, AsyncScheduler()) +elif six.PY3: + from _target_schema_async import target_schema as target_schema_async + target_application["async-sync"] = run_async(target_schema_sync) + target_application["async-async"] = run_async(target_schema_async) diff --git a/tests/framework_graphql/_target_schema_async.py b/tests/framework_graphql/_target_schema_async.py new file mode 100644 index 0000000000..b57c36bffe --- /dev/null +++ b/tests/framework_graphql/_target_schema_async.py @@ -0,0 +1,187 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + GraphQLUnionType, +) + +try: + from _target_schema_sync import books, libraries, magazines +except ImportError: + from framework_graphql._target_schema_sync import books, libraries, magazines + +storage = [] + + +async def resolve_library(parent, info, index): + return libraries[index] + + +async def resolve_storage_add(parent, info, string): + storage.append(string) + return string + + +async def resolve_storage(parent, info): + return [storage.pop()] + + +async def resolve_search(parent, info, contains): + search_books = [b for b in books if contains in b["name"]] + search_magazines = [m for m in magazines if contains in m["name"]] + return search_books + search_magazines + + +Author = GraphQLObjectType( + "Author", + { + "first_name": GraphQLField(GraphQLString), + "last_name": GraphQLField(GraphQLString), + }, +) + +Book = GraphQLObjectType( + "Book", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "isbn": GraphQLField(GraphQLString), + "author": GraphQLField(Author), + "branch": GraphQLField(GraphQLString), + }, +) + +Magazine = GraphQLObjectType( + "Magazine", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "issue": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + }, +) + + +Library = GraphQLObjectType( + "Library", + { + "id": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + "book": GraphQLField(GraphQLList(Book)), + "magazine": GraphQLField(GraphQLList(Magazine)), + }, +) + +Storage = GraphQLList(GraphQLString) + + +async def resolve_hello(root, info): + return "Hello!" + + +async def resolve_echo(root, info, echo): + return echo + + +async def resolve_error(root, info): + raise RuntimeError("Runtime Error!") + + +try: + hello_field = GraphQLField(GraphQLString, resolver=resolve_hello) + library_field = GraphQLField( + Library, + resolver=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolver=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolver=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolver=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolver=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolver=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolver=resolve_hello) +except TypeError: + hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) + library_field = GraphQLField( + Library, + resolve=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolve=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolve=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolve=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolve=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolve=resolve_hello) + +query = GraphQLObjectType( + name="Query", + fields={ + "hello": hello_field, + "library": library_field, + "search": search_field, + "echo": echo_field, + "storage": storage_field, + "error": error_field, + "error_non_null": error_non_null_field, + "error_middleware": error_middleware_field, + }, +) + +mutation = GraphQLObjectType( + name="Mutation", + fields={ + "storage_add": storage_add_field, + }, +) + +target_schema = GraphQLSchema(query=query, mutation=mutation) diff --git a/tests/framework_graphql/_target_schema_promise.py b/tests/framework_graphql/_target_schema_promise.py new file mode 100644 index 0000000000..ea08639e14 --- /dev/null +++ b/tests/framework_graphql/_target_schema_promise.py @@ -0,0 +1,194 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + GraphQLUnionType, +) +from promise import Promise, promisify + +try: + from _target_schema_sync import books, libraries, magazines +except ImportError: + from framework_graphql._target_schema_sync import books, libraries, magazines + +storage = [] + + +@promisify +def resolve_library(parent, info, index): + return libraries[index] + + +@promisify +def resolve_storage_add(parent, info, string): + storage.append(string) + return string + + +@promisify +def resolve_storage(parent, info): + return [storage.pop()] + + +def resolve_search(parent, info, contains): + search_books = [b for b in books if contains in b["name"]] + search_magazines = [m for m in magazines if contains in m["name"]] + return search_books + search_magazines + + +Author = GraphQLObjectType( + "Author", + { + "first_name": GraphQLField(GraphQLString), + "last_name": GraphQLField(GraphQLString), + }, +) + +Book = GraphQLObjectType( + "Book", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "isbn": GraphQLField(GraphQLString), + "author": GraphQLField(Author), + "branch": GraphQLField(GraphQLString), + }, +) + +Magazine = GraphQLObjectType( + "Magazine", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "issue": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + }, +) + + +Library = GraphQLObjectType( + "Library", + { + "id": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + "book": GraphQLField(GraphQLList(Book)), + "magazine": GraphQLField(GraphQLList(Magazine)), + }, +) + +Storage = GraphQLList(GraphQLString) + + +@promisify +def resolve_hello(root, info): + return "Hello!" + + +@promisify +def resolve_echo(root, info, echo): + return echo + + +@promisify +def resolve_error(root, info): + raise RuntimeError("Runtime Error!") + + +try: + hello_field = GraphQLField(GraphQLString, resolver=resolve_hello) + library_field = GraphQLField( + Library, + resolver=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolver=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolver=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolver=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolver=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolver=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolver=resolve_hello) +except TypeError: + hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) + library_field = GraphQLField( + Library, + resolve=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolve=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolve=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolve=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolve=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolve=resolve_hello) + +query = GraphQLObjectType( + name="Query", + fields={ + "hello": hello_field, + "library": library_field, + "search": search_field, + "echo": echo_field, + "storage": storage_field, + "error": error_field, + "error_non_null": error_non_null_field, + "error_middleware": error_middleware_field, + }, +) + +mutation = GraphQLObjectType( + name="Mutation", + fields={ + "storage_add": storage_add_field, + }, +) + +target_schema = GraphQLSchema(query=query, mutation=mutation) diff --git a/tests/framework_graphql/_target_schema_sync.py b/tests/framework_graphql/_target_schema_sync.py new file mode 100644 index 0000000000..ddfd8d190a --- /dev/null +++ b/tests/framework_graphql/_target_schema_sync.py @@ -0,0 +1,239 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applic`ab`le law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + GraphQLUnionType, +) + +authors = [ + { + "first_name": "New", + "last_name": "Relic", + }, + { + "first_name": "Bob", + "last_name": "Smith", + }, + { + "first_name": "Leslie", + "last_name": "Jones", + }, +] + +books = [ + { + "id": 1, + "name": "Python Agent: The Book", + "isbn": "a-fake-isbn", + "author": authors[0], + "branch": "riverside", + }, + { + "id": 2, + "name": "Ollies for O11y: A Sk8er's Guide to Observability", + "isbn": "a-second-fake-isbn", + "author": authors[1], + "branch": "downtown", + }, + { + "id": 3, + "name": "[Redacted]", + "isbn": "a-third-fake-isbn", + "author": authors[2], + "branch": "riverside", + }, +] + +magazines = [ + {"id": 1, "name": "Reli Updates Weekly", "issue": 1, "branch": "riverside"}, + {"id": 2, "name": "Reli Updates Weekly", "issue": 2, "branch": "downtown"}, + {"id": 3, "name": "Node Weekly", "issue": 1, "branch": "riverside"}, +] + + +libraries = ["riverside", "downtown"] +libraries = [ + { + "id": i + 1, + "branch": branch, + "magazine": [m for m in magazines if m["branch"] == branch], + "book": [b for b in books if b["branch"] == branch], + } + for i, branch in enumerate(libraries) +] + +storage = [] + + +def resolve_library(parent, info, index): + return libraries[index] + + +def resolve_storage_add(parent, info, string): + storage.append(string) + return string + + +def resolve_storage(parent, info): + return [storage.pop()] + + +def resolve_search(parent, info, contains): + search_books = [b for b in books if contains in b["name"]] + search_magazines = [m for m in magazines if contains in m["name"]] + return search_books + search_magazines + + +Author = GraphQLObjectType( + "Author", + { + "first_name": GraphQLField(GraphQLString), + "last_name": GraphQLField(GraphQLString), + }, +) + +Book = GraphQLObjectType( + "Book", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "isbn": GraphQLField(GraphQLString), + "author": GraphQLField(Author), + "branch": GraphQLField(GraphQLString), + }, +) + +Magazine = GraphQLObjectType( + "Magazine", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "issue": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + }, +) + + +Library = GraphQLObjectType( + "Library", + { + "id": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + "book": GraphQLField(GraphQLList(Book)), + "magazine": GraphQLField(GraphQLList(Magazine)), + }, +) + +Storage = GraphQLList(GraphQLString) + + +def resolve_hello(root, info): + return "Hello!" + + +def resolve_echo(root, info, echo): + return echo + + +def resolve_error(root, info): + raise RuntimeError("Runtime Error!") + + +try: + hello_field = GraphQLField(GraphQLString, resolver=resolve_hello) + library_field = GraphQLField( + Library, + resolver=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolver=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolver=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolver=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolver=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolver=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolver=resolve_hello) +except TypeError: + hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) + library_field = GraphQLField( + Library, + resolve=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolve=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolve=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolve=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolve=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolve=resolve_hello) + +query = GraphQLObjectType( + name="Query", + fields={ + "hello": hello_field, + "library": library_field, + "search": search_field, + "echo": echo_field, + "storage": storage_field, + "error": error_field, + "error_non_null": error_non_null_field, + "error_middleware": error_middleware_field, + }, +) + +mutation = GraphQLObjectType( + name="Mutation", + fields={ + "storage_add": storage_add_field, + }, +) + +target_schema = GraphQLSchema(query=query, mutation=mutation) diff --git a/tests/framework_graphql/conftest.py b/tests/framework_graphql/conftest.py index 2084a5bb46..9aa8b65404 100644 --- a/tests/framework_graphql/conftest.py +++ b/tests/framework_graphql/conftest.py @@ -13,13 +13,14 @@ # limitations under the License. import pytest -import six from testing_support.fixtures import ( code_coverage_fixture, collector_agent_registration_fixture, collector_available_fixture, ) +from newrelic.packages import six + _coverage_source = [ "newrelic.hooks.framework_graphql", ] @@ -39,12 +40,19 @@ default_settings=_default_settings, ) +apps = ["sync-sync", "async-sync", "async-async", "sync-promise", "async-promise"] + + +@pytest.fixture(scope="session", params=apps) +def target_application(request): + from _target_application import target_application -@pytest.fixture(scope="session") -def app(): - from _target_application import _target_application + app = target_application.get(request.param, None) + if app is None: + pytest.skip("Unsupported combination.") + return - return _target_application + return "GraphQL", None, app, True, request.param.split("-")[1] if six.PY2: diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index 770c6f6e15..3f765becee 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -18,14 +18,26 @@ validate_transaction_errors, validate_transaction_metrics, ) +from testing_support.validators.validate_code_level_metrics import ( + validate_code_level_metrics, +) from testing_support.validators.validate_span_events import validate_span_events from testing_support.validators.validate_transaction_count import ( validate_transaction_count, ) -from testing_support.validators.validate_code_level_metrics import validate_code_level_metrics from newrelic.api.background_task import background_task from newrelic.common.object_names import callable_name +from newrelic.packages import six + + +def conditional_decorator(decorator, condition): + def _conditional_decorator(func): + if not condition: + return func + return decorator(func) + + return _conditional_decorator @pytest.fixture(scope="session") @@ -36,16 +48,6 @@ def is_graphql_2(): return major_version == 2 -@pytest.fixture(scope="session") -def graphql_run(): - try: - from graphql import graphql_sync as graphql - except ImportError: - from graphql import graphql - - return graphql - - def to_graphql_source(query): def delay_import(): try: @@ -75,58 +77,78 @@ def error_middleware(next, root, info, **args): raise RuntimeError("Runtime Error!") +example_middleware = [example_middleware] +error_middleware = [error_middleware] + +if six.PY3: + from test_application_async import error_middleware_async, example_middleware_async + + example_middleware.append(example_middleware_async) + error_middleware.append(error_middleware_async) + + _runtime_error_name = callable_name(RuntimeError) _test_runtime_error = [(_runtime_error_name, "Runtime Error!")] -_graphql_base_rollup_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 1), - ("GraphQL/allOther", 1), - ("GraphQL/GraphQL/all", 1), - ("GraphQL/GraphQL/allOther", 1), -] -def test_basic(app, graphql_run): - from graphql import __version__ as version +def _graphql_base_rollup_metrics(framework, version, background_task=True): + from graphql import __version__ as graphql_version - FRAMEWORK_METRICS = [ - ("Python/Framework/GraphQL/%s" % version, 1), + metrics = [ + ("Python/Framework/GraphQL/%s" % graphql_version, 1), + ("GraphQL/all", 1), + ("GraphQL/%s/all" % framework, 1), ] + if background_task: + metrics.extend( + [ + ("GraphQL/allOther", 1), + ("GraphQL/%s/allOther" % framework, 1), + ] + ) + else: + metrics.extend( + [ + ("GraphQL/allWeb", 1), + ("GraphQL/%s/allWeb" % framework, 1), + ] + ) + + if framework != "GraphQL": + metrics.append(("Python/Framework/%s/%s" % (framework, version), 1)) + + return metrics + + +def test_basic(target_application): + framework, version, target_application, is_bg, schema_type = target_application @validate_transaction_metrics( "query//hello", "GraphQL", - rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, - background_task=True, + rollup_metrics=_graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, '{ hello }') - assert not response.errors - + response = target_application("{ hello }") + assert response["hello"] == "Hello!" + _test() @dt_enabled -def test_query_and_mutation(app, graphql_run, is_graphql_2): - from graphql import __version__ as version +def test_query_and_mutation(target_application, is_graphql_2): + framework, version, target_application, is_bg, schema_type = target_application - FRAMEWORK_METRICS = [ - ("Python/Framework/GraphQL/%s" % version, 1), - ] _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/GraphQL/storage", 1), - ("GraphQL/resolve/GraphQL/storage_add", 1), - ("GraphQL/operation/GraphQL/query//storage", 1), - ("GraphQL/operation/GraphQL/mutation//storage_add", 1), + ("GraphQL/resolve/%s/storage_add" % framework, 1), + ("GraphQL/operation/%s/mutation//storage_add" % framework, 1), + ] + _test_query_scoped_metrics = [ + ("GraphQL/resolve/%s/storage" % framework, 1), + ("GraphQL/operation/%s/query//storage" % framework, 1), ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/GraphQL/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/GraphQL/allOther", 2), - ] + _test_mutation_scoped_metrics _expected_mutation_operation_attributes = { "graphql.operation.type": "mutation", @@ -136,7 +158,7 @@ def test_query_and_mutation(app, graphql_run, is_graphql_2): "graphql.field.name": "storage_add", "graphql.field.parentType": "Mutation", "graphql.field.path": "storage_add", - "graphql.field.returnType": "[String]" if is_graphql_2 else "String", + "graphql.field.returnType": "String", } _expected_query_operation_attributes = { "graphql.operation.type": "query", @@ -149,75 +171,97 @@ def test_query_and_mutation(app, graphql_run, is_graphql_2): "graphql.field.returnType": "[String]", } - @validate_code_level_metrics("_target_application", "resolve_storage") - @validate_code_level_metrics("_target_application", "resolve_storage_add") + @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_storage_add") @validate_transaction_metrics( - "query//storage", + "mutation//storage_add", "GraphQL", scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, + rollup_metrics=_test_mutation_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) @validate_span_events(exact_agents=_expected_mutation_operation_attributes) @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) + @conditional_decorator(background_task(), is_bg) + def _mutation(): + response = target_application('mutation { storage_add(string: "abc") }') + assert response["storage_add"] == "abc" + + @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_storage") + @validate_transaction_metrics( + "query//storage", + "GraphQL", + scoped_metrics=_test_query_scoped_metrics, + rollup_metrics=_test_query_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, + ) @validate_span_events(exact_agents=_expected_query_operation_attributes) @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - response = graphql_run(app, 'mutation { storage_add(string: "abc") }') - assert not response.errors - response = graphql_run(app, "query { storage }") - assert not response.errors + @conditional_decorator(background_task(), is_bg) + def _query(): + response = target_application("query { storage }") + assert response["storage"] == ["abc"] - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.data) - assert "abc" in str(response.data) - - _test() + _mutation() + _query() +@pytest.mark.parametrize("middleware", example_middleware) @dt_enabled -def test_middleware(app, graphql_run, is_graphql_2): +def test_middleware(target_application, middleware): + framework, version, target_application, is_bg, schema_type = target_application + + name = "%s:%s" % (middleware.__module__, middleware.__name__) + if "async" in name: + if schema_type != "async": + pytest.skip("Async middleware not supported in sync applications.") + _test_middleware_metrics = [ ("GraphQL/operation/GraphQL/query//hello", 1), ("GraphQL/resolve/GraphQL/hello", 1), - ("Function/test_application:example_middleware", 1), + ("Function/%s" % name, 1), ] - @validate_code_level_metrics("test_application", "example_middleware") - @validate_code_level_metrics("_target_application", "resolve_hello") + @validate_code_level_metrics(*name.split(":")) + @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_hello") @validate_transaction_metrics( "query//hello", "GraphQL", scoped_metrics=_test_middleware_metrics, - rollup_metrics=_test_middleware_metrics + _graphql_base_rollup_metrics, - background_task=True, + rollup_metrics=_test_middleware_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) # Span count 5: Transaction, Operation, Middleware, and 1 Resolver and Resolver Function @validate_span_events(count=5) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, "{ hello }", middleware=[example_middleware]) - assert not response.errors - assert "Hello!" in str(response.data) + response = target_application("{ hello }", middleware=[middleware]) + assert response["hello"] == "Hello!" _test() +@pytest.mark.parametrize("middleware", error_middleware) @dt_enabled -def test_exception_in_middleware(app, graphql_run): - query = "query MyQuery { hello }" - field = "hello" +def test_exception_in_middleware(target_application, middleware): + framework, version, target_application, is_bg, schema_type = target_application + query = "query MyQuery { error_middleware }" + field = "error_middleware" + + name = "%s:%s" % (middleware.__module__, middleware.__name__) + if "async" in name: + if schema_type != "async": + pytest.skip("Async middleware not supported in sync applications.") # Metrics _test_exception_scoped_metrics = [ - ("GraphQL/operation/GraphQL/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/GraphQL/%s" % field, 1), + ("GraphQL/operation/%s/query/MyQuery/%s" % (framework, field), 1), + ("GraphQL/resolve/%s/%s" % (framework, field), 1), + ("Function/%s" % name, 1), ] _test_exception_rollup_metrics = [ ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/test_application:error_middleware", 1), + ("Errors/all%s" % ("Other" if is_bg else "Web"), 1), + ("Errors/%sTransaction/GraphQL/%s" % ("Other" if is_bg else "Web", name), 1), ] + _test_exception_scoped_metrics # Attributes @@ -234,39 +278,39 @@ def test_exception_in_middleware(app, graphql_run): } @validate_transaction_metrics( - "test_application:error_middleware", + name, "GraphQL", scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) @validate_span_events(exact_agents=_expected_exception_operation_attributes) @validate_span_events(exact_agents=_expected_exception_resolver_attributes) @validate_transaction_errors(errors=_test_runtime_error) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, query, middleware=[error_middleware]) - assert response.errors + response = target_application(query, middleware=[middleware]) _test() @pytest.mark.parametrize("field", ("error", "error_non_null")) @dt_enabled -def test_exception_in_resolver(app, graphql_run, field): +def test_exception_in_resolver(target_application, field): + framework, version, target_application, is_bg, schema_type = target_application query = "query MyQuery { %s }" % field - txn_name = "_target_application:resolve_error" + txn_name = "_target_schema_%s:resolve_error" % schema_type # Metrics _test_exception_scoped_metrics = [ - ("GraphQL/operation/GraphQL/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/GraphQL/%s" % field, 1), + ("GraphQL/operation/%s/query/MyQuery/%s" % (framework, field), 1), + ("GraphQL/resolve/%s/%s" % (framework, field), 1), ] _test_exception_rollup_metrics = [ ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), + ("Errors/all%s" % ("Other" if is_bg else "Web"), 1), + ("Errors/%sTransaction/GraphQL/%s" % ("Other" if is_bg else "Web", txn_name), 1), ] + _test_exception_scoped_metrics # Attributes @@ -286,16 +330,15 @@ def test_exception_in_resolver(app, graphql_run, field): txn_name, "GraphQL", scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) @validate_span_events(exact_agents=_expected_exception_operation_attributes) @validate_span_events(exact_agents=_expected_exception_resolver_attributes) @validate_transaction_errors(errors=_test_runtime_error) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, query) - assert response.errors + response = target_application(query) _test() @@ -304,11 +347,12 @@ def _test(): @pytest.mark.parametrize( "query,exc_class", [ - ("query MyQuery { missing_field }", "GraphQLError"), + ("query MyQuery { error_missing_field }", "GraphQLError"), ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), ], ) -def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_class): +def test_exception_in_validation(target_application, is_graphql_2, query, exc_class): + framework, version, target_application, is_bg, schema_type = target_application if "syntax" in query: txn_name = "graphql.language.parser:parse" else: @@ -324,12 +368,12 @@ def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_clas exc_class = callable_name(GraphQLError) _test_exception_scoped_metrics = [ - # ('GraphQL/operation/GraphQL///', 1), + ("GraphQL/operation/%s///" % framework, 1), ] _test_exception_rollup_metrics = [ ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), + ("Errors/all%s" % ("Other" if is_bg else "Web"), 1), + ("Errors/%sTransaction/GraphQL/%s" % ("Other" if is_bg else "Web", txn_name), 1), ] + _test_exception_scoped_metrics # Attributes @@ -343,22 +387,22 @@ def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_clas txn_name, "GraphQL", scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, + rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) @validate_span_events(exact_agents=_expected_exception_operation_attributes) @validate_transaction_errors(errors=[exc_class]) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, query) - assert response.errors + response = target_application(query) _test() @dt_enabled -def test_operation_metrics_and_attrs(app, graphql_run): - operation_metrics = [("GraphQL/operation/GraphQL/query/MyQuery/library", 1)] +def test_operation_metrics_and_attrs(target_application): + framework, version, target_application, is_bg, schema_type = target_application + operation_metrics = [("GraphQL/operation/%s/query/MyQuery/library" % framework, 1)] operation_attrs = { "graphql.operation.type": "query", "graphql.operation.name": "MyQuery", @@ -368,27 +412,26 @@ def test_operation_metrics_and_attrs(app, graphql_run): "query/MyQuery/library", "GraphQL", scoped_metrics=operation_metrics, - rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, - background_task=True, + rollup_metrics=operation_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) # Span count 16: Transaction, Operation, and 7 Resolvers and Resolver functions # library, library.name, library.book # library.book.name and library.book.id for each book resolved (in this case 2) @validate_span_events(count=16) @validate_span_events(exact_agents=operation_attrs) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run( - app, "query MyQuery { library(index: 0) { branch, book { id, name } } }" - ) - assert not response.errors + response = target_application("query MyQuery { library(index: 0) { branch, book { id, name } } }") _test() @dt_enabled -def test_field_resolver_metrics_and_attrs(app, graphql_run): - field_resolver_metrics = [("GraphQL/resolve/GraphQL/hello", 1)] +def test_field_resolver_metrics_and_attrs(target_application): + framework, version, target_application, is_bg, schema_type = target_application + field_resolver_metrics = [("GraphQL/resolve/%s/hello" % framework, 1)] + graphql_attrs = { "graphql.field.name": "hello", "graphql.field.parentType": "Query", @@ -400,17 +443,16 @@ def test_field_resolver_metrics_and_attrs(app, graphql_run): "query//hello", "GraphQL", scoped_metrics=field_resolver_metrics, - rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, - background_task=True, + rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, ) # Span count 4: Transaction, Operation, and 1 Resolver and Resolver function @validate_span_events(count=4) @validate_span_events(exact_agents=graphql_attrs) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, "{ hello }") - assert not response.errors - assert "Hello!" in str(response.data) + response = target_application("{ hello }") + assert response["hello"] == "Hello!" _test() @@ -433,18 +475,19 @@ def _test(): @dt_enabled @pytest.mark.parametrize("query,obfuscated", _test_queries) -def test_query_obfuscation(app, graphql_run, query, obfuscated): +def test_query_obfuscation(target_application, query, obfuscated): + framework, version, target_application, is_bg, schema_type = target_application graphql_attrs = {"graphql.operation.query": obfuscated} if callable(query): + if framework != "GraphQL": + pytest.skip("Source query objects not tested outside of graphql-core") query = query() @validate_span_events(exact_agents=graphql_attrs) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, query) - if not isinstance(query, str) or "error" not in query: - assert not response.errors + response = target_application(query) _test() @@ -489,28 +532,31 @@ def _test(): @dt_enabled @pytest.mark.parametrize("query,expected_path", _test_queries) -def test_deepest_unique_path(app, graphql_run, query, expected_path): +def test_deepest_unique_path(target_application, query, expected_path): + framework, version, target_application, is_bg, schema_type = target_application if expected_path == "/error": - txn_name = "_target_application:resolve_error" + txn_name = "_target_schema_%s:resolve_error" % schema_type else: txn_name = "query/%s" % expected_path @validate_transaction_metrics( txn_name, "GraphQL", - background_task=True, + background_task=is_bg, ) - @background_task() + @conditional_decorator(background_task(), is_bg) def _test(): - response = graphql_run(app, query) - if "error" not in query: - assert not response.errors + response = target_application(query) _test() -@validate_transaction_count(0) -@background_task() -def test_ignored_introspection_transactions(app, graphql_run): - response = graphql_run(app, "{ __schema { types { name } } }") - assert not response.errors +def test_ignored_introspection_transactions(target_application): + framework, version, target_application, is_bg, schema_type = target_application + + @validate_transaction_count(0) + @background_task() + def _test(): + response = target_application("{ __schema { types { name } } }") + + _test() diff --git a/tests/framework_graphql/test_application_async.py b/tests/framework_graphql/test_application_async.py index 19b8b14936..39c1871ef6 100644 --- a/tests/framework_graphql/test_application_async.py +++ b/tests/framework_graphql/test_application_async.py @@ -12,98 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio +from inspect import isawaitable -import pytest -from test_application import is_graphql_2 -from testing_support.fixtures import dt_enabled, validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events -from newrelic.api.background_task import background_task +# Async Functions not allowed in Py2 +async def example_middleware_async(next, root, info, **args): + return_value = next(root, info, **args) + if isawaitable(return_value): + return await return_value + return return_value -@pytest.fixture(scope="session") -def graphql_run_async(): - from graphql import __version__ as version - from graphql import graphql - - major_version = int(version.split(".")[0]) - if major_version == 2: - - def graphql_run(*args, **kwargs): - return graphql(*args, return_promise=True, **kwargs) - - return graphql_run - else: - return graphql - - -@dt_enabled -def test_query_and_mutation_async(app, graphql_run_async, is_graphql_2): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/GraphQL/storage", 1), - ("GraphQL/resolve/GraphQL/storage_add", 1), - ("GraphQL/operation/GraphQL/query//storage", 1), - ("GraphQL/operation/GraphQL/mutation//storage_add", 1), - ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/GraphQL/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/GraphQL/allOther", 2), - ] + _test_mutation_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "[String]" if is_graphql_2 else "String", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - async def coro(): - response = await graphql_run_async(app, 'mutation { storage_add(string: "abc") }') - assert not response.errors - response = await graphql_run_async(app, "query { storage }") - assert not response.errors - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.data) - assert "abc" in str(response.data) - - loop = asyncio.new_event_loop() - loop.run_until_complete(coro()) - - _test() +async def error_middleware_async(next, root, info, **args): + raise RuntimeError("Runtime Error!") diff --git a/tox.ini b/tox.ini index 074d44d181..164fe6ced3 100644 --- a/tox.ini +++ b/tox.ini @@ -125,8 +125,8 @@ envlist = python-framework_graphene-{py36,py37,py38,py39,py310}-graphenelatest, python-framework_graphene-{py27,py36,py37,py38,py39,pypy,pypy3}-graphene{0200,0201}, python-framework_graphene-py310-graphene0201, - python-framework_graphql-{py27,py36,py37,py38,py39,py310,pypy,pypy36}-graphql02, - python-framework_graphql-{py36,py37,py38,py39,py310,pypy36}-graphql03, + python-framework_graphql-{py27,py36,py37,py38,py39,py310,pypy,pypy3}-graphql02, + python-framework_graphql-{py36,py37,py38,py39,py310,pypy3}-graphql03, python-framework_graphql-py37-graphql{0202,0203,0300,0301,0302,master}, grpc-framework_grpc-{py27,py36}-grpc0125, grpc-framework_grpc-{py36,py37,py38,py39,py310}-grpclatest, From 56cab4b1eca2b25185cd89e390ddc7a7a6024736 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Wed, 27 Apr 2022 16:21:25 -0700 Subject: [PATCH 10/26] Fix graphql impl coros (#522) --- newrelic/hooks/framework_graphql.py | 9 +++++++-- newrelic/hooks/framework_graphql_py3.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index dd2c31f703..dc5575d4be 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -59,6 +59,7 @@ def as_promise(f): nr_coro_execute_name_wrapper, nr_coro_resolver_error_wrapper, nr_coro_resolver_wrapper, + nr_coro_graphql_impl_wrapper, ) _logger = logging.getLogger(__name__) @@ -564,10 +565,10 @@ def wrap_graphql_impl(wrapped, instance, args, kwargs): # Otherwise subsequent instrumentation will not be able to find an operation trace and will have issues. trace.__enter__() try: - result = wrapped(*args, **kwargs) + with ErrorTrace(ignore=ignore_graphql_duplicate_exception): + result = wrapped(*args, **kwargs) except Exception as e: # Execution finished synchronously, exit immediately. - notice_error(ignore=ignore_graphql_duplicate_exception) trace.__exit__(*sys.exc_info()) raise else: @@ -583,6 +584,10 @@ def on_reject(e): return e return result.then(on_resolve, on_reject) + elif isawaitable(result) and not is_promise(result): + # Asynchronous implementations + # Return a coroutine that handles closing the operation trace + return nr_coro_graphql_impl_wrapper(wrapped, trace, ignore_graphql_duplicate_exception, result) else: # Execution finished synchronously, exit immediately. trace.__exit__(None, None, None) diff --git a/newrelic/hooks/framework_graphql_py3.py b/newrelic/hooks/framework_graphql_py3.py index d46fb59886..c335ae5dd0 100644 --- a/newrelic/hooks/framework_graphql_py3.py +++ b/newrelic/hooks/framework_graphql_py3.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. import functools +import sys from newrelic.api.error_trace import ErrorTrace from newrelic.api.function_trace import FunctionTrace @@ -49,3 +50,19 @@ async def _nr_coro_resolver_wrapper(): return await result return _nr_coro_resolver_wrapper() + +def nr_coro_graphql_impl_wrapper(wrapped, trace, ignore, result): + @functools.wraps(wrapped) + async def _nr_coro_graphql_impl_wrapper(): + try: + with ErrorTrace(ignore=ignore): + result_ = await result + except: + trace.__exit__(*sys.exc_info()) + raise + else: + trace.__exit__(None, None, None) + return result_ + + + return _nr_coro_graphql_impl_wrapper() \ No newline at end of file From fd57192ea440e34e06ce4ef07b18b065755510b6 Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Tue, 3 May 2022 15:28:25 -0700 Subject: [PATCH 11/26] Strawberry Async Updates (#521) * Parameterize strawberry tests * Remove duplicate functions * Fix strawberry version testing * Updates * Finalize strawberry updates * Clean out code --- newrelic/hooks/framework_graphql.py | 11 +- newrelic/hooks/framework_graphql_py3.py | 4 +- newrelic/hooks/framework_strawberry.py | 14 +- tests/framework_graphql/test_application.py | 14 +- .../_target_application.py | 216 +++------ .../_target_schema_async.py | 81 ++++ .../_target_schema_sync.py | 169 +++++++ tests/framework_strawberry/conftest.py | 7 - .../framework_strawberry/test_application.py | 441 +----------------- .../test_application_async.py | 144 ------ tests/framework_strawberry/test_asgi.py | 122 ----- 11 files changed, 350 insertions(+), 873 deletions(-) create mode 100644 tests/framework_strawberry/_target_schema_async.py create mode 100644 tests/framework_strawberry/_target_schema_sync.py delete mode 100644 tests/framework_strawberry/test_application_async.py delete mode 100644 tests/framework_strawberry/test_asgi.py diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index dc5575d4be..b5af068f03 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -381,8 +381,11 @@ def wrap_resolver(wrapped, instance, args, kwargs): if transaction is None: return wrapped(*args, **kwargs) - name = callable_name(wrapped) + base_resolver = getattr(wrapped, "_nr_base_resolver", wrapped) + + name = callable_name(base_resolver) transaction.set_transaction_name(name, "GraphQL", priority=13) + trace = FunctionTrace(name, source=base_resolver) with ErrorTrace(ignore=ignore_graphql_duplicate_exception): sync_start_time = time.time() @@ -391,7 +394,7 @@ def wrap_resolver(wrapped, instance, args, kwargs): if is_promise(result) and result.is_pending and graphql_version() < (3, 0): @functools.wraps(wrapped) def nr_promise_resolver_error_wrapper(v): - with FunctionTrace(name, source=wrapped): + with trace: with ErrorTrace(ignore=ignore_graphql_duplicate_exception): try: return result.get() @@ -402,10 +405,10 @@ def nr_promise_resolver_error_wrapper(v): elif isawaitable(result) and not is_promise(result): # Grab any async resolvers and wrap with traces return nr_coro_resolver_error_wrapper( - wrapped, name, ignore_graphql_duplicate_exception, result, transaction + wrapped, name, trace, ignore_graphql_duplicate_exception, result, transaction ) else: - with FunctionTrace(name, source=wrapped) as trace: + with trace: trace.start_time = sync_start_time if is_promise(result) and result.is_rejected: result.catch(catch_promise_error).get() diff --git a/newrelic/hooks/framework_graphql_py3.py b/newrelic/hooks/framework_graphql_py3.py index c335ae5dd0..3931aa6ed6 100644 --- a/newrelic/hooks/framework_graphql_py3.py +++ b/newrelic/hooks/framework_graphql_py3.py @@ -28,10 +28,10 @@ async def _nr_coro_execute_name_wrapper(): return _nr_coro_execute_name_wrapper() -def nr_coro_resolver_error_wrapper(wrapped, name, ignore, result, transaction): +def nr_coro_resolver_error_wrapper(wrapped, name, trace, ignore, result, transaction): @functools.wraps(wrapped) async def _nr_coro_resolver_error_wrapper(): - with FunctionTrace(name, source=wrapped): + with trace: with ErrorTrace(ignore=ignore): try: return await result diff --git a/newrelic/hooks/framework_strawberry.py b/newrelic/hooks/framework_strawberry.py index 92a0ea8b4e..cfbe450d6c 100644 --- a/newrelic/hooks/framework_strawberry.py +++ b/newrelic/hooks/framework_strawberry.py @@ -29,7 +29,16 @@ def framework_details(): import strawberry - return ("Strawberry", getattr(strawberry, "__version__", None)) + try: + version = strawberry.__version__ + except Exception: + try: + import pkg_resources + version = pkg_resources.get_distribution("strawberry-graphql").version + except Exception: + version = None + + return ("Strawberry", version) def bind_execute(query, *args, **kwargs): @@ -104,8 +113,7 @@ def wrap_from_resolver(wrapped, instance, args, kwargs): else: if hasattr(field, "base_resolver"): if hasattr(field.base_resolver, "wrapped_func"): - resolver_name = callable_name(field.base_resolver.wrapped_func) - result = TransactionNameWrapper(result, resolver_name, "GraphQL", priority=13) + result._nr_base_resolver = field.base_resolver.wrapped_func return result diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index 3f765becee..b620af2671 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -81,7 +81,10 @@ def error_middleware(next, root, info, **args): error_middleware = [error_middleware] if six.PY3: - from test_application_async import error_middleware_async, example_middleware_async + try: + from test_application_async import error_middleware_async, example_middleware_async + except ImportError: + from framework_graphql.test_application_async import error_middleware_async, example_middleware_async example_middleware.append(example_middleware_async) error_middleware.append(error_middleware_async) @@ -141,6 +144,8 @@ def _test(): def test_query_and_mutation(target_application, is_graphql_2): framework, version, target_application, is_bg, schema_type = target_application + type_annotation = "!" if framework == "Strawberry" else "" + _test_mutation_scoped_metrics = [ ("GraphQL/resolve/%s/storage_add" % framework, 1), ("GraphQL/operation/%s/mutation//storage_add" % framework, 1), @@ -158,7 +163,7 @@ def test_query_and_mutation(target_application, is_graphql_2): "graphql.field.name": "storage_add", "graphql.field.parentType": "Mutation", "graphql.field.path": "storage_add", - "graphql.field.returnType": "String", + "graphql.field.returnType": "String" + type_annotation, } _expected_query_operation_attributes = { "graphql.operation.type": "query", @@ -168,7 +173,7 @@ def test_query_and_mutation(target_application, is_graphql_2): "graphql.field.name": "storage", "graphql.field.parentType": "Query", "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", + "graphql.field.returnType": "[String%s]%s" % (type_annotation, type_annotation), } @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_storage_add") @@ -432,11 +437,12 @@ def test_field_resolver_metrics_and_attrs(target_application): framework, version, target_application, is_bg, schema_type = target_application field_resolver_metrics = [("GraphQL/resolve/%s/hello" % framework, 1)] + type_annotation = "!" if framework == "Strawberry" else "" graphql_attrs = { "graphql.field.name": "hello", "graphql.field.parentType": "Query", "graphql.field.path": "hello", - "graphql.field.returnType": "String", + "graphql.field.returnType": "String" + type_annotation, } @validate_transaction_metrics( diff --git a/tests/framework_strawberry/_target_application.py b/tests/framework_strawberry/_target_application.py index e032fc27ae..29737db97d 100644 --- a/tests/framework_strawberry/_target_application.py +++ b/tests/framework_strawberry/_target_application.py @@ -12,185 +12,79 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List, Union - -import strawberry.mutation -import strawberry.type -from strawberry import Schema, field -from strawberry.asgi import GraphQL -from strawberry.schema.config import StrawberryConfig -from strawberry.types.types import Optional - - -@strawberry.type -class Author: - first_name: str - last_name: str - - -@strawberry.type -class Book: - id: int - name: str - isbn: str - author: Author - branch: str - - -@strawberry.type -class Magazine: - id: int - name: str - issue: int - branch: str - -@strawberry.type -class Library: - id: int - branch: str - magazine: List[Magazine] - book: List[Book] +import asyncio +import json +import pytest +from _target_schema_sync import target_schema as target_schema_sync, target_asgi_application as target_asgi_application_sync +from _target_schema_async import target_schema as target_schema_async, target_asgi_application as target_asgi_application_async -Item = Union[Book, Magazine] -Storage = List[str] +def run_sync(schema): + def _run_sync(query, middleware=None): + from graphql.language.source import Source -authors = [ - Author( - first_name="New", - last_name="Relic", - ), - Author( - first_name="Bob", - last_name="Smith", - ), - Author( - first_name="Leslie", - last_name="Jones", - ), -] - -books = [ - Book( - id=1, - name="Python Agent: The Book", - isbn="a-fake-isbn", - author=authors[0], - branch="riverside", - ), - Book( - id=2, - name="Ollies for O11y: A Sk8er's Guide to Observability", - isbn="a-second-fake-isbn", - author=authors[1], - branch="downtown", - ), - Book( - id=3, - name="[Redacted]", - isbn="a-third-fake-isbn", - author=authors[2], - branch="riverside", - ), -] + if middleware is not None: + pytest.skip("Middleware not supported in Strawberry.") -magazines = [ - Magazine(id=1, name="Reli Updates Weekly", issue=1, branch="riverside"), - Magazine(id=2, name="Reli: The Forgotten Years", issue=2, branch="downtown"), - Magazine(id=3, name="Node Weekly", issue=1, branch="riverside"), -] + response = schema.execute_sync(query) + if isinstance(query, str) and "error" not in query or isinstance(query, Source) and "error" not in query.body: + assert not response.errors + else: + assert response.errors -libraries = ["riverside", "downtown"] -libraries = [ - Library( - id=i + 1, - branch=branch, - magazine=[m for m in magazines if m.branch == branch], - book=[b for b in books if b.branch == branch], - ) - for i, branch in enumerate(libraries) -] + return response.data + return _run_sync -storage = [] +def run_async(schema): + def _run_async(query, middleware=None): + from graphql.language.source import Source -def resolve_hello(): - return "Hello!" + if middleware is not None: + pytest.skip("Middleware not supported in Strawberry.") + loop = asyncio.get_event_loop() + response = loop.run_until_complete(schema.execute(query)) -async def resolve_hello_async(): - return "Hello!" + if isinstance(query, str) and "error" not in query or isinstance(query, Source) and "error" not in query.body: + assert not response.errors + else: + assert response.errors + return response.data + return _run_async -def resolve_echo(echo: str): - return echo +def run_asgi(app): + def _run_asgi(query, middleware=None): + if middleware is not None: + pytest.skip("Middleware not supported in Strawberry.") -def resolve_library(index: int): - return libraries[index] + response = app.make_request( + "POST", "/", body=json.dumps({"query": query}), headers={"Content-Type": "application/json"} + ) + body = json.loads(response.body.decode("utf-8")) + if not isinstance(query, str) or "error" in query: + try: + assert response.status != 200 + except AssertionError: + assert body["errors"] + else: + assert response.status == 200 + assert "errors" not in body or not body["errors"] -def resolve_storage_add(string: str): - storage.add(string) - return storage + return body["data"] + return _run_asgi -def resolve_storage(): - return storage - - -def resolve_error(): - raise RuntimeError("Runtime Error!") - - -def resolve_search(contains: str): - search_books = [b for b in books if contains in b.name] - search_magazines = [m for m in magazines if contains in m.name] - return search_books + search_magazines - - -@strawberry.type -class Query: - library: Library = field(resolver=resolve_library) - hello: str = field(resolver=resolve_hello) - hello_async: str = field(resolver=resolve_hello_async) - search: List[Item] = field(resolver=resolve_search) - echo: str = field(resolver=resolve_echo) - storage: Storage = field(resolver=resolve_storage) - error: Optional[str] = field(resolver=resolve_error) - error_non_null: str = field(resolver=resolve_error) - - def resolve_library(self, info, index): - return libraries[index] - - def resolve_storage(self, info): - return storage - - def resolve_search(self, info, contains): - search_books = [b for b in books if contains in b.name] - search_magazines = [m for m in magazines if contains in m.name] - return search_books + search_magazines - - def resolve_hello(self, info): - return "Hello!" - - def resolve_echo(self, info, echo): - return echo - - def resolve_error(self, info) -> str: - raise RuntimeError("Runtime Error!") - - -@strawberry.type -class Mutation: - @strawberry.mutation - def storage_add(self, string: str) -> str: - storage.append(string) - return str(string) - - -_target_application = Schema(query=Query, mutation=Mutation, config=StrawberryConfig(auto_camel_case=False)) -_target_asgi_application = GraphQL(_target_application) +target_application = { + "sync-sync": run_sync(target_schema_sync), + "async-sync": run_async(target_schema_sync), + "asgi-sync": run_asgi(target_asgi_application_sync), + "async-async": run_async(target_schema_async), + "asgi-async": run_asgi(target_asgi_application_async), +} diff --git a/tests/framework_strawberry/_target_schema_async.py b/tests/framework_strawberry/_target_schema_async.py new file mode 100644 index 0000000000..0b4953eb70 --- /dev/null +++ b/tests/framework_strawberry/_target_schema_async.py @@ -0,0 +1,81 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List +import strawberry.mutation +import strawberry.type +from strawberry import Schema, field +from strawberry.asgi import GraphQL +from strawberry.schema.config import StrawberryConfig +from strawberry.types.types import Optional +from testing_support.asgi_testing import AsgiTest + +try: + from _target_schema_sync import Library, Item, Storage, books, magazines, libraries +except ImportError: + from framework_strawberry._target_schema_sync import Library, Item, Storage, books, magazines, libraries + + +storage = [] + + +async def resolve_hello(): + return "Hello!" + + +async def resolve_echo(echo: str): + return echo + + +async def resolve_library(index: int): + return libraries[index] + + +async def resolve_storage_add(string: str): + storage.append(string) + return string + + +async def resolve_storage(): + return [storage.pop()] + + +async def resolve_error(): + raise RuntimeError("Runtime Error!") + + +async def resolve_search(contains: str): + search_books = [b for b in books if contains in b.name] + search_magazines = [m for m in magazines if contains in m.name] + return search_books + search_magazines + + +@strawberry.type +class Query: + library: Library = field(resolver=resolve_library) + hello: str = field(resolver=resolve_hello) + search: List[Item] = field(resolver=resolve_search) + echo: str = field(resolver=resolve_echo) + storage: Storage = field(resolver=resolve_storage) + error: Optional[str] = field(resolver=resolve_error) + error_non_null: str = field(resolver=resolve_error) + + +@strawberry.type +class Mutation: + storage_add: str = strawberry.mutation(resolver=resolve_storage_add) + + +target_schema = Schema(query=Query, mutation=Mutation, config=StrawberryConfig(auto_camel_case=False)) +target_asgi_application = AsgiTest(GraphQL(target_schema)) diff --git a/tests/framework_strawberry/_target_schema_sync.py b/tests/framework_strawberry/_target_schema_sync.py new file mode 100644 index 0000000000..34bff75b91 --- /dev/null +++ b/tests/framework_strawberry/_target_schema_sync.py @@ -0,0 +1,169 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List, Union + +import strawberry.mutation +import strawberry.type +from strawberry import Schema, field +from strawberry.asgi import GraphQL +from strawberry.schema.config import StrawberryConfig +from strawberry.types.types import Optional +from testing_support.asgi_testing import AsgiTest + + +@strawberry.type +class Author: + first_name: str + last_name: str + + +@strawberry.type +class Book: + id: int + name: str + isbn: str + author: Author + branch: str + + +@strawberry.type +class Magazine: + id: int + name: str + issue: int + branch: str + + +@strawberry.type +class Library: + id: int + branch: str + magazine: List[Magazine] + book: List[Book] + + +Item = Union[Book, Magazine] +Storage = List[str] + + +authors = [ + Author( + first_name="New", + last_name="Relic", + ), + Author( + first_name="Bob", + last_name="Smith", + ), + Author( + first_name="Leslie", + last_name="Jones", + ), +] + +books = [ + Book( + id=1, + name="Python Agent: The Book", + isbn="a-fake-isbn", + author=authors[0], + branch="riverside", + ), + Book( + id=2, + name="Ollies for O11y: A Sk8er's Guide to Observability", + isbn="a-second-fake-isbn", + author=authors[1], + branch="downtown", + ), + Book( + id=3, + name="[Redacted]", + isbn="a-third-fake-isbn", + author=authors[2], + branch="riverside", + ), +] + +magazines = [ + Magazine(id=1, name="Reli Updates Weekly", issue=1, branch="riverside"), + Magazine(id=2, name="Reli: The Forgotten Years", issue=2, branch="downtown"), + Magazine(id=3, name="Node Weekly", issue=1, branch="riverside"), +] + + +libraries = ["riverside", "downtown"] +libraries = [ + Library( + id=i + 1, + branch=branch, + magazine=[m for m in magazines if m.branch == branch], + book=[b for b in books if b.branch == branch], + ) + for i, branch in enumerate(libraries) +] + +storage = [] + + +def resolve_hello(): + return "Hello!" + + +def resolve_echo(echo: str): + return echo + + +def resolve_library(index: int): + return libraries[index] + + +def resolve_storage_add(string: str): + storage.append(string) + return string + + +def resolve_storage(): + return [storage.pop()] + + +def resolve_error(): + raise RuntimeError("Runtime Error!") + + +def resolve_search(contains: str): + search_books = [b for b in books if contains in b.name] + search_magazines = [m for m in magazines if contains in m.name] + return search_books + search_magazines + + +@strawberry.type +class Query: + library: Library = field(resolver=resolve_library) + hello: str = field(resolver=resolve_hello) + search: List[Item] = field(resolver=resolve_search) + echo: str = field(resolver=resolve_echo) + storage: Storage = field(resolver=resolve_storage) + error: Optional[str] = field(resolver=resolve_error) + error_non_null: str = field(resolver=resolve_error) + + +@strawberry.type +class Mutation: + storage_add: str = strawberry.mutation(resolver=resolve_storage_add) + + +target_schema = Schema(query=Query, mutation=Mutation, config=StrawberryConfig(auto_camel_case=False)) +target_asgi_application = AsgiTest(GraphQL(target_schema)) diff --git a/tests/framework_strawberry/conftest.py b/tests/framework_strawberry/conftest.py index 6cbf75b879..10c659b8bb 100644 --- a/tests/framework_strawberry/conftest.py +++ b/tests/framework_strawberry/conftest.py @@ -40,12 +40,5 @@ ) -@pytest.fixture(scope="session") -def app(): - from _target_application import _target_application - - return _target_application - - if six.PY2: collect_ignore = ["test_application_async.py"] diff --git a/tests/framework_strawberry/test_application.py b/tests/framework_strawberry/test_application.py index 945a613d40..be17465162 100644 --- a/tests/framework_strawberry/test_application.py +++ b/tests/framework_strawberry/test_application.py @@ -12,435 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest -from testing_support.fixtures import ( - dt_enabled, - validate_transaction_errors, - validate_transaction_metrics, -) -from testing_support.validators.validate_span_events import validate_span_events -from testing_support.validators.validate_transaction_count import ( - validate_transaction_count, -) +from framework_graphql.test_application import * -from newrelic.api.background_task import background_task -from newrelic.common.object_names import callable_name +@pytest.fixture(scope="session", params=["sync-sync", "async-sync", "async-async", "asgi-sync", "asgi-async"]) +def target_application(request): + from _target_application import target_application + target_application = target_application[request.param] -@pytest.fixture(scope="session") -def is_graphql_2(): - from graphql import __version__ as version + try: + import strawberry + version = strawberry.__version__ + except Exception: + import pkg_resources + version = pkg_resources.get_distribution("strawberry-graphql").version - major_version = int(version.split(".")[0]) - return major_version == 2 + is_asgi = "asgi" in request.param + schema_type = request.param.split("-")[1] + assert version is not None + return "Strawberry", version, target_application, not is_asgi, schema_type -@pytest.fixture(scope="session") -def graphql_run(): - """Wrapper function to simulate framework_graphql test behavior.""" - - def execute(schema, *args, **kwargs): - return schema.execute_sync(*args, **kwargs) - - return execute - - -def to_graphql_source(query): - def delay_import(): - try: - from graphql import Source - except ImportError: - # Fallback if Source is not implemented - return query - - from graphql import __version__ as version - - # For graphql2, Source objects aren't acceptable input - major_version = int(version.split(".")[0]) - if major_version == 2: - return query - - return Source(query) - - return delay_import - - -def example_middleware(next, root, info, **args): #pylint: disable=W0622 - return_value = next(root, info, **args) - return return_value - - -def error_middleware(next, root, info, **args): #pylint: disable=W0622 - raise RuntimeError("Runtime Error!") - - -_runtime_error_name = callable_name(RuntimeError) -_test_runtime_error = [(_runtime_error_name, "Runtime Error!")] -_graphql_base_rollup_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 1), - ("GraphQL/allOther", 1), - ("GraphQL/Strawberry/all", 1), - ("GraphQL/Strawberry/allOther", 1), -] - - -def test_basic(app, graphql_run): - from graphql import __version__ as version - - from newrelic.hooks.framework_strawberry import framework_details - - FRAMEWORK_METRICS = [ - ("Python/Framework/Strawberry/%s" % framework_details()[1], 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @background_task() - def _test(): - response = graphql_run(app, "{ hello }") - assert not response.errors - assert response.data["hello"] == "Hello!" - - _test() - - -@dt_enabled -def test_query_and_mutation(app, graphql_run): - from graphql import __version__ as version - - from newrelic.hooks.framework_strawberry import framework_details - - FRAMEWORK_METRICS = [ - ("Python/Framework/Strawberry/%s" % framework_details()[1], 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Strawberry/storage", 1), - ("GraphQL/resolve/Strawberry/storage_add", 1), - ("GraphQL/operation/Strawberry/query//storage", 1), - ("GraphQL/operation/Strawberry/mutation//storage_add", 1), - ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/Strawberry/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/Strawberry/allOther", 2), - ] + _test_mutation_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "String!", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String!]!", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - response = graphql_run(app, 'mutation { storage_add(string: "abc") }') - assert not response.errors - response = graphql_run(app, "query { storage }") - assert not response.errors - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.data) - assert "abc" in str(response.data) - - _test() - - -@pytest.mark.parametrize("field", ("error", "error_non_null")) -@dt_enabled -def test_exception_in_resolver(app, graphql_run, field): - query = "query MyQuery { %s }" % field - - txn_name = "_target_application:resolve_error" - - # Metrics - _test_exception_scoped_metrics = [ - ("GraphQL/operation/Strawberry/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/Strawberry/%s" % field, 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_resolver_attributes = { - "graphql.field.name": field, - "graphql.field.parentType": "Query", - "graphql.field.path": field, - "graphql.field.returnType": "String!" if "non_null" in field else "String", - } - _expected_exception_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - txn_name, - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_span_events(exact_agents=_expected_exception_resolver_attributes) - @validate_transaction_errors(errors=_test_runtime_error) - @background_task() - def _test(): - response = graphql_run(app, query) - assert response.errors - - _test() - - -@dt_enabled -@pytest.mark.parametrize( - "query,exc_class", - [ - ("query MyQuery { missing_field }", "GraphQLError"), - ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), - ], -) -def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_class): - if "syntax" in query: - txn_name = "graphql.language.parser:parse" - else: - if is_graphql_2: - txn_name = "graphql.validation.validation:validate" - else: - txn_name = "graphql.validation.validate:validate" - - # Import path differs between versions - if exc_class == "GraphQLError": - from graphql.error import GraphQLError - - exc_class = callable_name(GraphQLError) - - _test_exception_scoped_metrics = [ - ('GraphQL/operation/Strawberry///', 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_operation_attributes = { - "graphql.operation.type": "", - "graphql.operation.name": "", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - txn_name, - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_transaction_errors(errors=[exc_class]) - @background_task() - def _test(): - response = graphql_run(app, query) - assert response.errors - - _test() - - -@dt_enabled -def test_operation_metrics_and_attrs(app, graphql_run): - operation_metrics = [("GraphQL/operation/Strawberry/query/MyQuery/library", 1)] - operation_attrs = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - } - - @validate_transaction_metrics( - "query/MyQuery/library", - "GraphQL", - scoped_metrics=operation_metrics, - rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 16: Transaction, Operation, and 7 Resolvers and Resolver functions - # library, library.name, library.book - # library.book.name and library.book.id for each book resolved (in this case 2) - @validate_span_events(count=16) - @validate_span_events(exact_agents=operation_attrs) - @background_task() - def _test(): - response = graphql_run(app, "query MyQuery { library(index: 0) { branch, book { id, name } } }") - assert not response.errors - - _test() - - -@dt_enabled -def test_field_resolver_metrics_and_attrs(app, graphql_run): - field_resolver_metrics = [("GraphQL/resolve/Strawberry/hello", 1)] - graphql_attrs = { - "graphql.field.name": "hello", - "graphql.field.parentType": "Query", - "graphql.field.path": "hello", - "graphql.field.returnType": "String!", - } - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - scoped_metrics=field_resolver_metrics, - rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 4: Transaction, Operation, and 1 Resolver and Resolver function - @validate_span_events(count=4) - @validate_span_events(exact_agents=graphql_attrs) - @background_task() - def _test(): - response = graphql_run(app, "{ hello }") - assert not response.errors - assert "Hello!" in str(response.data) - - _test() - - -_test_queries = [ - ("{ hello }", "{ hello }"), # Basic query extraction - ("{ error }", "{ error }"), # Extract query on field error - (to_graphql_source("{ hello }"), "{ hello }"), # Extract query from Source objects - ( - "{ library(index: 0) { branch } }", - "{ library(index: ?) { branch } }", - ), # Integers - ('{ echo(echo: "123") }', "{ echo(echo: ?) }"), # Strings with numerics - ('{ echo(echo: "test") }', "{ echo(echo: ?) }"), # Strings - ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Aliases - ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Variables - ( # Fragments - '{ ...MyFragment } fragment MyFragment on Query { echo(echo: "test") }', - "{ ...MyFragment } fragment MyFragment on Query { echo(echo: ?) }", - ), -] - - -@dt_enabled -@pytest.mark.parametrize("query,obfuscated", _test_queries) -def test_query_obfuscation(app, graphql_run, query, obfuscated): - graphql_attrs = {"graphql.operation.query": obfuscated} - - if callable(query): - query = query() - - @validate_span_events(exact_agents=graphql_attrs) - @background_task() - def _test(): - response = graphql_run(app, query) - if not isinstance(query, str) or "error" not in query: - assert not response.errors - - _test() - - -_test_queries = [ - ("{ hello }", "/hello"), # Basic query - ("{ error }", "/error"), # Extract deepest path on field error - ('{ echo(echo: "test") }', "/echo"), # Fields with arguments - ( - "{ library(index: 0) { branch, book { isbn branch } } }", - "/library", - ), # Complex Example, 1 level - ( - "{ library(index: 0) { book { author { first_name }} } }", - "/library.book.author.first_name", - ), # Complex Example, 2 levels - ("{ library(index: 0) { id, book { name } } }", "/library.book.name"), # Filtering - ('{ TestEcho: echo(echo: "test") }', "/echo"), # Aliases - ( - '{ search(contains: "A") { __typename ... on Book { name } } }', - "/search.name", - ), # InlineFragment - ( - '{ hello echo(echo: "test") }', - "", - ), # Multiple root selections. (need to decide on final behavior) - # FragmentSpread - ( - "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { name id }", # Fragment filtering - "/library.book.name", - ), - ( - "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", - "/library.book.author.first_name", - ), - ( - "{ library(index: 0) { book { ...MyFragment } magazine { ...MagFragment } } } fragment MyFragment on Book { author { first_name } } fragment MagFragment on Magazine { name }", - "/library", - ), -] - - -@dt_enabled -@pytest.mark.parametrize("query,expected_path", _test_queries) -def test_deepest_unique_path(app, graphql_run, query, expected_path): - if expected_path == "/error": - txn_name = "_target_application:resolve_error" - else: - txn_name = "query/%s" % expected_path - - @validate_transaction_metrics( - txn_name, - "GraphQL", - background_task=True, - ) - @background_task() - def _test(): - response = graphql_run(app, query) - if "error" not in query: - assert not response.errors - - _test() - - -@validate_transaction_count(0) -@background_task() -def test_ignored_introspection_transactions(app, graphql_run): - response = graphql_run(app, "{ __schema { types { name } } }") - assert not response.errors diff --git a/tests/framework_strawberry/test_application_async.py b/tests/framework_strawberry/test_application_async.py deleted file mode 100644 index 85d109955e..0000000000 --- a/tests/framework_strawberry/test_application_async.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright 2010 New Relic, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio - -import pytest -from testing_support.fixtures import dt_enabled, validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events - -from newrelic.api.background_task import background_task - - -@pytest.fixture(scope="session") -def graphql_run_async(): - """Wrapper function to simulate framework_graphql test behavior.""" - - def execute(schema, *args, **kwargs): - return schema.execute(*args, **kwargs) - - return execute - - -_graphql_base_rollup_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 1), - ("GraphQL/allOther", 1), - ("GraphQL/Strawberry/all", 1), - ("GraphQL/Strawberry/allOther", 1), -] - - -loop = asyncio.new_event_loop() - - -def test_basic_async(app, graphql_run_async): - from graphql import __version__ as version - - from newrelic.hooks.framework_strawberry import framework_details - - FRAMEWORK_METRICS = [ - ("Python/Framework/Strawberry/%s" % framework_details()[1], 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - - @validate_transaction_metrics( - "query//hello_async", - "GraphQL", - rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @background_task() - def _test(): - async def coro(): - response = await graphql_run_async(app, "{ hello_async }") - assert not response.errors - assert response.data["hello_async"] == "Hello!" - - loop.run_until_complete(coro()) - - _test() - - -@dt_enabled -def test_query_and_mutation_async(app, graphql_run_async): - from graphql import __version__ as version - - from newrelic.hooks.framework_strawberry import framework_details - - FRAMEWORK_METRICS = [ - ("Python/Framework/Strawberry/%s" % framework_details()[1], 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Strawberry/storage", 1), - ("GraphQL/resolve/Strawberry/storage_add", 1), - ("GraphQL/operation/Strawberry/query//storage", 1), - ("GraphQL/operation/Strawberry/mutation//storage_add", 1), - ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/Strawberry/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/Strawberry/allOther", 2), - ] + _test_mutation_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "String!", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String!]!", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - async def coro(): - response = await graphql_run_async(app, 'mutation { storage_add(string: "abc") }') - assert not response.errors - response = await graphql_run_async(app, "query { storage }") - assert not response.errors - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.data) - assert "abc" in str(response.data) - - loop.run_until_complete(coro()) - - _test() diff --git a/tests/framework_strawberry/test_asgi.py b/tests/framework_strawberry/test_asgi.py deleted file mode 100644 index 0db1e8a58f..0000000000 --- a/tests/framework_strawberry/test_asgi.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2010 New Relic, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json - -import pytest -from testing_support.asgi_testing import AsgiTest -from testing_support.fixtures import dt_enabled, validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events - - -@pytest.fixture(scope="session") -def graphql_asgi_run(): - """Wrapper function to simulate framework_graphql test behavior.""" - from _target_application import _target_asgi_application - - app = AsgiTest(_target_asgi_application) - - def execute(query): - return app.make_request( - "POST", - "/", - headers={"Content-Type": "application/json"}, - body=json.dumps({"query": query}), - ) - - return execute - - -@dt_enabled -def test_query_and_mutation_asgi(graphql_asgi_run): - from graphql import __version__ as version - - from newrelic.hooks.framework_strawberry import framework_details - - FRAMEWORK_METRICS = [ - ("Python/Framework/Strawberry/%s" % framework_details()[1], 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Strawberry/storage_add", 1), - ("GraphQL/operation/Strawberry/mutation//storage_add", 1), - ] - _test_query_scoped_metrics = [ - ("GraphQL/resolve/Strawberry/storage", 1), - ("GraphQL/operation/Strawberry/query//storage", 1), - ] - _test_unscoped_metrics = [ - ("WebTransaction", 1), - ("GraphQL/all", 1), - ("GraphQL/Strawberry/all", 1), - ("GraphQL/allWeb", 1), - ("GraphQL/Strawberry/allWeb", 1), - ] - _test_mutation_unscoped_metrics = _test_unscoped_metrics + _test_mutation_scoped_metrics - _test_query_unscoped_metrics = _test_unscoped_metrics + _test_query_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "String!", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String!]!", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_query_scoped_metrics, - rollup_metrics=_test_query_unscoped_metrics + FRAMEWORK_METRICS, - ) - @validate_transaction_metrics( - "mutation//storage_add", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - index=-2, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes, index=-2) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes, index=-2) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - def _test(): - response = graphql_asgi_run('mutation { storage_add(string: "abc") }') - assert response.status == 200 - response = json.loads(response.body.decode("utf-8")) - assert not response.get("errors") - - response = graphql_asgi_run("query { storage }") - assert response.status == 200 - response = json.loads(response.body.decode("utf-8")) - assert not response.get("errors") - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.get("data")) - assert "abc" in str(response.get("data")) - - _test() From ff9b1b43103e7f0ca6dc5965fb1f0cae6818217f Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Mon, 9 May 2022 10:40:03 -0700 Subject: [PATCH 12/26] Ariadne Async Testing (#523) * Parameterize ariadne tests * Fixing ariadne tests * Fixing ariadne middleware * Set 0 extra spans for graphql core tests * Add spans attr to strawberry tests --- newrelic/hooks/framework_ariadne.py | 14 +- .../framework_ariadne/_target_application.py | 184 +++---- .../framework_ariadne/_target_schema_async.py | 94 ++++ .../framework_ariadne/_target_schema_sync.py | 97 ++++ tests/framework_ariadne/conftest.py | 7 - tests/framework_ariadne/schema.graphql | 7 +- tests/framework_ariadne/test_application.py | 510 +----------------- .../test_application_async.py | 105 ---- tests/framework_ariadne/test_asgi.py | 117 ---- tests/framework_ariadne/test_wsgi.py | 114 ---- tests/framework_graphql/conftest.py | 2 +- tests/framework_graphql/test_application.py | 48 +- .../framework_strawberry/test_application.py | 2 +- 13 files changed, 327 insertions(+), 974 deletions(-) create mode 100644 tests/framework_ariadne/_target_schema_async.py create mode 100644 tests/framework_ariadne/_target_schema_sync.py delete mode 100644 tests/framework_ariadne/test_application_async.py delete mode 100644 tests/framework_ariadne/test_asgi.py delete mode 100644 tests/framework_ariadne/test_wsgi.py diff --git a/newrelic/hooks/framework_ariadne.py b/newrelic/hooks/framework_ariadne.py index 498c662c49..7d55a89b8d 100644 --- a/newrelic/hooks/framework_ariadne.py +++ b/newrelic/hooks/framework_ariadne.py @@ -29,9 +29,17 @@ def framework_details(): - import ariadne - - return ("Ariadne", getattr(ariadne, "__version__", None)) + try: + import ariadne + version = ariadne.__version__ + except Exception: + try: + import pkg_resources + version = pkg_resources.get_distribution("ariadne").version + except Exception: + version = None + + return ("Ariadne", version) def bind_graphql(schema, data, *args, **kwargs): diff --git a/tests/framework_ariadne/_target_application.py b/tests/framework_ariadne/_target_application.py index 94bc0710f5..bf6ef75c8f 100644 --- a/tests/framework_ariadne/_target_application.py +++ b/tests/framework_ariadne/_target_application.py @@ -12,140 +12,110 @@ # See the License for the specific language governing permissions and # limitations under the License. -import os - -from ariadne import ( - MutationType, - QueryType, - UnionType, - load_schema_from_path, - make_executable_schema, -) -from ariadne.asgi import GraphQL as GraphQLASGI -from ariadne.wsgi import GraphQL as GraphQLWSGI - -schema_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "schema.graphql") -type_defs = load_schema_from_path(schema_file) - - -authors = [ - { - "first_name": "New", - "last_name": "Relic", - }, - { - "first_name": "Bob", - "last_name": "Smith", - }, - { - "first_name": "Leslie", - "last_name": "Jones", - }, -] -books = [ - { - "id": 1, - "name": "Python Agent: The Book", - "isbn": "a-fake-isbn", - "author": authors[0], - "branch": "riverside", - }, - { - "id": 2, - "name": "Ollies for O11y: A Sk8er's Guide to Observability", - "isbn": "a-second-fake-isbn", - "author": authors[1], - "branch": "downtown", - }, - { - "id": 3, - "name": "[Redacted]", - "isbn": "a-third-fake-isbn", - "author": authors[2], - "branch": "riverside", - }, -] +import asyncio +import json +import pytest -magazines = [ - {"id": 1, "name": "Reli Updates Weekly", "issue": 1, "branch": "riverside"}, - {"id": 2, "name": "Reli Updates Weekly", "issue": 2, "branch": "downtown"}, - {"id": 3, "name": "Node Weekly", "issue": 1, "branch": "riverside"}, -] +from _target_schema_sync import target_schema as target_schema_sync, target_asgi_application as target_asgi_application_sync, target_wsgi_application as target_wsgi_application_sync +from _target_schema_async import target_schema as target_schema_async, target_asgi_application as target_asgi_application_async +from graphql import MiddlewareManager -libraries = ["riverside", "downtown"] -libraries = [ - { - "id": i + 1, - "branch": branch, - "magazine": [m for m in magazines if m["branch"] == branch], - "book": [b for b in books if b["branch"] == branch], - } - for i, branch in enumerate(libraries) -] -storage = [] +def check_response(query, success, response): + if isinstance(query, str) and "error" not in query: + assert success and not "errors" in response, response["errors"] + assert response["data"] + else: + assert "errors" in response, response -mutation = MutationType() +def run_sync(schema): + def _run_sync(query, middleware=None): + from ariadne import graphql_sync -@mutation.field("storage_add") -def mutate(self, info, string): - storage.append(string) - return {"string": string} + if middleware: + middleware = MiddlewareManager(*middleware) + else: + middleware = None + success, response = graphql_sync(schema, {"query": query}, middleware=middleware) + check_response(query, success, response) -item = UnionType("Item") + return response.get("data", {}) + return _run_sync -@item.type_resolver -def resolve_type(obj, *args): - if "isbn" in obj: - return "Book" - elif "issue" in obj: # pylint: disable=R1705 - return "Magazine" +def run_async(schema): + def _run_async(query, middleware=None): + from ariadne import graphql - return None + if middleware: + middleware = MiddlewareManager(*middleware) + else: + middleware = None + loop = asyncio.get_event_loop() + success, response = loop.run_until_complete(graphql(schema, {"query": query}, middleware=middleware)) + check_response(query, success, response) -query = QueryType() + return response.get("data", {}) + return _run_async -@query.field("library") -def resolve_library(self, info, index): - return libraries[index] +def run_wsgi(app): + def _run_asgi(query, middleware=None): + if not isinstance(query, str) or "error" in query: + expect_errors = True + else: + expect_errors = False + app.app.middleware = middleware -@query.field("storage") -def resolve_storage(self, info): - return storage + response = app.post( + "/", json.dumps({"query": query}), headers={"Content-Type": "application/json"}, expect_errors=expect_errors + ) + body = json.loads(response.body.decode("utf-8")) + if expect_errors: + assert body["errors"] + else: + assert "errors" not in body or not body["errors"] -@query.field("search") -def resolve_search(self, info, contains): - search_books = [b for b in books if contains in b["name"]] - search_magazines = [m for m in magazines if contains in m["name"]] - return search_books + search_magazines + return body.get("data", {}) + return _run_asgi -@query.field("hello") -def resolve_hello(self, info): - return "Hello!" +def run_asgi(app): + def _run_asgi(query, middleware=None): + app.asgi_application.middleware = middleware -@query.field("echo") -def resolve_echo(self, info, echo): - return echo + response = app.make_request( + "POST", "/", body=json.dumps({"query": query}), headers={"Content-Type": "application/json"} + ) + body = json.loads(response.body.decode("utf-8")) + if not isinstance(query, str) or "error" in query: + try: + assert response.status != 200 + except AssertionError: + assert body["errors"] + else: + assert response.status == 200 + assert "errors" not in body or not body["errors"] -@query.field("error_non_null") -@query.field("error") -def resolve_error(self, info): - raise RuntimeError("Runtime Error!") + return body.get("data", {}) + return _run_asgi -_target_application = make_executable_schema(type_defs, query, mutation, item) -_target_asgi_application = GraphQLASGI(_target_application) -_target_wsgi_application = GraphQLWSGI(_target_application) +target_application = { + "sync-sync": run_sync(target_schema_sync), + "async-sync": run_async(target_schema_sync), + "async-async": run_async(target_schema_async), + "wsgi-sync": run_wsgi(target_wsgi_application_sync), + "asgi-sync": run_asgi(target_asgi_application_sync), + "asgi-async": run_asgi(target_asgi_application_async), +} diff --git a/tests/framework_ariadne/_target_schema_async.py b/tests/framework_ariadne/_target_schema_async.py new file mode 100644 index 0000000000..076475628d --- /dev/null +++ b/tests/framework_ariadne/_target_schema_async.py @@ -0,0 +1,94 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +from ariadne import ( + MutationType, + QueryType, + UnionType, + load_schema_from_path, + make_executable_schema, +) +from ariadne.asgi import GraphQL as GraphQLASGI +from framework_graphql._target_schema_sync import books, magazines, libraries + +from testing_support.asgi_testing import AsgiTest + +schema_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "schema.graphql") +type_defs = load_schema_from_path(schema_file) + +storage = [] + +mutation = MutationType() + + +@mutation.field("storage_add") +async def resolve_storage_add(self, info, string): + storage.append(string) + return string + + +item = UnionType("Item") + + +@item.type_resolver +async def resolve_type(obj, *args): + if "isbn" in obj: + return "Book" + elif "issue" in obj: # pylint: disable=R1705 + return "Magazine" + + return None + + +query = QueryType() + + +@query.field("library") +async def resolve_library(self, info, index): + return libraries[index] + + +@query.field("storage") +async def resolve_storage(self, info): + return [storage.pop()] + + +@query.field("search") +async def resolve_search(self, info, contains): + search_books = [b for b in books if contains in b["name"]] + search_magazines = [m for m in magazines if contains in m["name"]] + return search_books + search_magazines + + +@query.field("hello") +@query.field("error_middleware") +async def resolve_hello(self, info): + return "Hello!" + + +@query.field("echo") +async def resolve_echo(self, info, echo): + return echo + + +@query.field("error_non_null") +@query.field("error") +async def resolve_error(self, info): + raise RuntimeError("Runtime Error!") + + +target_schema = make_executable_schema(type_defs, query, mutation, item) +target_asgi_application = AsgiTest(GraphQLASGI(target_schema)) diff --git a/tests/framework_ariadne/_target_schema_sync.py b/tests/framework_ariadne/_target_schema_sync.py new file mode 100644 index 0000000000..e42ee0bc17 --- /dev/null +++ b/tests/framework_ariadne/_target_schema_sync.py @@ -0,0 +1,97 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import webtest + +from ariadne import ( + MutationType, + QueryType, + UnionType, + load_schema_from_path, + make_executable_schema, +) +from ariadne.asgi import GraphQL as GraphQLASGI +from ariadne.wsgi import GraphQL as GraphQLWSGI +from framework_graphql._target_schema_sync import books, magazines, libraries + +from testing_support.asgi_testing import AsgiTest + +schema_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "schema.graphql") +type_defs = load_schema_from_path(schema_file) + +storage = [] + +mutation = MutationType() + + +@mutation.field("storage_add") +def resolve_storage_add(self, info, string): + storage.append(string) + return string + + +item = UnionType("Item") + + +@item.type_resolver +def resolve_type(obj, *args): + if "isbn" in obj: + return "Book" + elif "issue" in obj: # pylint: disable=R1705 + return "Magazine" + + return None + + +query = QueryType() + + +@query.field("library") +def resolve_library(self, info, index): + return libraries[index] + + +@query.field("storage") +def resolve_storage(self, info): + return [storage.pop()] + + +@query.field("search") +def resolve_search(self, info, contains): + search_books = [b for b in books if contains in b["name"]] + search_magazines = [m for m in magazines if contains in m["name"]] + return search_books + search_magazines + + +@query.field("hello") +@query.field("error_middleware") +def resolve_hello(self, info): + return "Hello!" + + +@query.field("echo") +def resolve_echo(self, info, echo): + return echo + + +@query.field("error_non_null") +@query.field("error") +def resolve_error(self, info): + raise RuntimeError("Runtime Error!") + + +target_schema = make_executable_schema(type_defs, query, mutation, item) +target_asgi_application = AsgiTest(GraphQLASGI(target_schema)) +target_wsgi_application = webtest.TestApp(GraphQLWSGI(target_schema)) diff --git a/tests/framework_ariadne/conftest.py b/tests/framework_ariadne/conftest.py index f7c94ed260..31a19f5a69 100644 --- a/tests/framework_ariadne/conftest.py +++ b/tests/framework_ariadne/conftest.py @@ -40,12 +40,5 @@ ) -@pytest.fixture(scope="session") -def app(): - from _target_application import _target_application - - return _target_application - - if six.PY2: collect_ignore = ["test_application_async.py"] diff --git a/tests/framework_ariadne/schema.graphql b/tests/framework_ariadne/schema.graphql index 4c76e0b88b..8bf64af512 100644 --- a/tests/framework_ariadne/schema.graphql +++ b/tests/framework_ariadne/schema.graphql @@ -33,7 +33,7 @@ type Magazine { } type Mutation { - storage_add(string: String!): StorageAdd + storage_add(string: String!): String } type Query { @@ -44,8 +44,5 @@ type Query { echo(echo: String!): String error: String error_non_null: String! -} - -type StorageAdd { - string: String + error_middleware: String } diff --git a/tests/framework_ariadne/test_application.py b/tests/framework_ariadne/test_application.py index f0b3587b87..40c63bf769 100644 --- a/tests/framework_ariadne/test_application.py +++ b/tests/framework_ariadne/test_application.py @@ -12,501 +12,25 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pytest -from testing_support.fixtures import ( - dt_enabled, - validate_transaction_errors, - validate_transaction_metrics, -) -from testing_support.validators.validate_span_events import validate_span_events -from testing_support.validators.validate_transaction_count import ( - validate_transaction_count, -) +from framework_graphql.test_application import * -from newrelic.api.background_task import background_task -from newrelic.common.object_names import callable_name +@pytest.fixture(scope="session", params=["sync-sync", "async-sync", "async-async", "wsgi-sync", "asgi-sync", "asgi-async"]) +def target_application(request): + from _target_application import target_application + target_application = target_application[request.param] -@pytest.fixture(scope="session") -def is_graphql_2(): - from graphql import __version__ as version + try: + import ariadne + version = ariadne.__version__ + except Exception: + import pkg_resources + version = pkg_resources.get_distribution("ariadne").version - major_version = int(version.split(".")[0]) - return major_version == 2 + param = request.param.split("-") + is_background = param[0] not in {"wsgi", "asgi"} + schema_type = param[1] + extra_spans = 4 if param[0] == "wsgi" else 0 - -@pytest.fixture(scope="session") -def graphql_run(): - """Wrapper function to simulate framework_graphql test behavior.""" - - def execute(schema, query, *args, **kwargs): - from ariadne import graphql_sync - - return graphql_sync(schema, {"query": query}, *args, **kwargs) - - return execute - - -def to_graphql_source(query): - def delay_import(): - try: - from graphql import Source - except ImportError: - # Fallback if Source is not implemented - return query - - from graphql import __version__ as version - - # For graphql2, Source objects aren't acceptable input - major_version = int(version.split(".")[0]) - if major_version == 2: - return query - - return Source(query) - - return delay_import - - -def example_middleware(next, root, info, **args): # pylint: disable=W0622 - return_value = next(root, info, **args) - return return_value - - -def error_middleware(next, root, info, **args): # pylint: disable=W0622 - raise RuntimeError("Runtime Error!") - - -_runtime_error_name = callable_name(RuntimeError) -_test_runtime_error = [(_runtime_error_name, "Runtime Error!")] -_graphql_base_rollup_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 1), - ("GraphQL/allOther", 1), - ("GraphQL/Ariadne/all", 1), - ("GraphQL/Ariadne/allOther", 1), -] - - -def test_basic(app, graphql_run): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/Ariadne/None", 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @background_task() - def _test(): - ok, response = graphql_run(app, "{ hello }") - assert ok and not response.get("errors") - - _test() - - -@dt_enabled -def test_query_and_mutation(app, graphql_run): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/Ariadne/None", 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Ariadne/storage", 1), - ("GraphQL/resolve/Ariadne/storage_add", 1), - ("GraphQL/operation/Ariadne/query//storage", 1), - ("GraphQL/operation/Ariadne/mutation//storage_add.string", 1), - ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/Ariadne/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/Ariadne/allOther", 2), - ] + _test_mutation_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "StorageAdd", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - ok, response = graphql_run(app, 'mutation { storage_add(string: "abc") { string } }') - assert ok and not response.get("errors") - ok, response = graphql_run(app, "query { storage }") - assert ok and not response.get("errors") - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response["data"]) - assert "abc" in str(response["data"]) - - _test() - - -@dt_enabled -def test_middleware(app, graphql_run, is_graphql_2): - _test_middleware_metrics = [ - ("GraphQL/operation/Ariadne/query//hello", 1), - ("GraphQL/resolve/Ariadne/hello", 1), - ("Function/test_application:example_middleware", 1), - ] - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - scoped_metrics=_test_middleware_metrics, - rollup_metrics=_test_middleware_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 5: Transaction, Operation, Middleware, and 1 Resolver and Resolver function - @validate_span_events(count=5) - @background_task() - def _test(): - from graphql import MiddlewareManager - - ok, response = graphql_run(app, "{ hello }", middleware=MiddlewareManager(example_middleware)) - assert ok and not response.get("errors") - assert "Hello!" in str(response["data"]) - - _test() - - -@dt_enabled -def test_exception_in_middleware(app, graphql_run): - query = "query MyQuery { hello }" - field = "hello" - - # Metrics - _test_exception_scoped_metrics = [ - ("GraphQL/operation/Ariadne/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/Ariadne/%s" % field, 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/test_application:error_middleware", 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_resolver_attributes = { - "graphql.field.name": field, - "graphql.field.parentType": "Query", - "graphql.field.path": field, - "graphql.field.returnType": "String", - } - _expected_exception_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - "test_application:error_middleware", - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_span_events(exact_agents=_expected_exception_resolver_attributes) - @validate_transaction_errors(errors=_test_runtime_error) - @background_task() - def _test(): - from graphql import MiddlewareManager - - _, response = graphql_run(app, query, middleware=MiddlewareManager(error_middleware)) - assert response["errors"] - - _test() - - -@pytest.mark.parametrize("field", ("error", "error_non_null")) -@dt_enabled -def test_exception_in_resolver(app, graphql_run, field): - query = "query MyQuery { %s }" % field - txn_name = "_target_application:resolve_error" - - # Metrics - _test_exception_scoped_metrics = [ - ("GraphQL/operation/Ariadne/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/Ariadne/%s" % field, 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_resolver_attributes = { - "graphql.field.name": field, - "graphql.field.parentType": "Query", - "graphql.field.path": field, - "graphql.field.returnType": "String!" if "non_null" in field else "String", - } - _expected_exception_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - txn_name, - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_span_events(exact_agents=_expected_exception_resolver_attributes) - @validate_transaction_errors(errors=_test_runtime_error) - @background_task() - def _test(): - _, response = graphql_run(app, query) - assert response["errors"] - - _test() - - -@dt_enabled -@pytest.mark.parametrize( - "query,exc_class", - [ - ("query MyQuery { missing_field }", "GraphQLError"), - ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), - ], -) -def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_class): - if "syntax" in query: - txn_name = "graphql.language.parser:parse" - else: - if is_graphql_2: - txn_name = "graphql.validation.validation:validate" - else: - txn_name = "graphql.validation.validate:validate" - - # Import path differs between versions - if exc_class == "GraphQLError": - from graphql.error import GraphQLError - - exc_class = callable_name(GraphQLError) - - _test_exception_scoped_metrics = [ - ('GraphQL/operation/Ariadne///', 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_operation_attributes = { - "graphql.operation.type": "", - "graphql.operation.name": "", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - txn_name, - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_transaction_errors(errors=[exc_class]) - @background_task() - def _test(): - _, response = graphql_run(app, query) - assert response["errors"] - - _test() - - -@dt_enabled -def test_operation_metrics_and_attrs(app, graphql_run): - operation_metrics = [("GraphQL/operation/Ariadne/query/MyQuery/library", 1)] - operation_attrs = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - } - - @validate_transaction_metrics( - "query/MyQuery/library", - "GraphQL", - scoped_metrics=operation_metrics, - rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 16: Transaction, Operation, and 7 Resolvers and Resolver functions - # library, library.name, library.book - # library.book.name and library.book.id for each book resolved (in this case 2) - @validate_span_events(count=16) - @validate_span_events(exact_agents=operation_attrs) - @background_task() - def _test(): - ok, response = graphql_run(app, "query MyQuery { library(index: 0) { branch, book { id, name } } }") - assert ok and not response.get("errors") - - _test() - - -@dt_enabled -def test_field_resolver_metrics_and_attrs(app, graphql_run): - field_resolver_metrics = [("GraphQL/resolve/Ariadne/hello", 1)] - graphql_attrs = { - "graphql.field.name": "hello", - "graphql.field.parentType": "Query", - "graphql.field.path": "hello", - "graphql.field.returnType": "String", - } - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - scoped_metrics=field_resolver_metrics, - rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 4: Transaction, Operation, and 1 Resolver and Resolver function - @validate_span_events(count=4) - @validate_span_events(exact_agents=graphql_attrs) - @background_task() - def _test(): - ok, response = graphql_run(app, "{ hello }") - assert ok and not response.get("errors") - assert "Hello!" in str(response["data"]) - - _test() - - -_test_queries = [ - ("{ hello }", "{ hello }"), # Basic query extraction - ("{ error }", "{ error }"), # Extract query on field error - ("{ library(index: 0) { branch } }", "{ library(index: ?) { branch } }"), # Integers - ('{ echo(echo: "123") }', "{ echo(echo: ?) }"), # Strings with numerics - ('{ echo(echo: "test") }', "{ echo(echo: ?) }"), # Strings - ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Aliases - ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Variables - ( # Fragments - '{ ...MyFragment } fragment MyFragment on Query { echo(echo: "test") }', - "{ ...MyFragment } fragment MyFragment on Query { echo(echo: ?) }", - ), -] - - -@dt_enabled -@pytest.mark.parametrize("query,obfuscated", _test_queries) -def test_query_obfuscation(app, graphql_run, query, obfuscated): - graphql_attrs = {"graphql.operation.query": obfuscated} - - @validate_span_events(exact_agents=graphql_attrs) - @background_task() - def _test(): - ok, response = graphql_run(app, query) - if not isinstance(query, str) or "error" not in query: - assert ok and not response.get("errors") - - _test() - - -_test_queries = [ - ("{ hello }", "/hello"), # Basic query - ("{ error }", "/error"), # Extract deepest path on field error - ('{ echo(echo: "test") }', "/echo"), # Fields with arguments - ( - "{ library(index: 0) { branch, book { isbn branch } } }", - "/library", - ), # Complex Example, 1 level - ( - "{ library(index: 0) { book { author { first_name }} } }", - "/library.book.author.first_name", - ), # Complex Example, 2 levels - ("{ library(index: 0) { id, book { name } } }", "/library.book.name"), # Filtering - ('{ TestEcho: echo(echo: "test") }', "/echo"), # Aliases - ( - '{ search(contains: "A") { __typename ... on Book { name } } }', - "/search.name", - ), # InlineFragment - ( - '{ hello echo(echo: "test") }', - "", - ), # Multiple root selections. (need to decide on final behavior) - # FragmentSpread - ( - "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { name id }", # Fragment filtering - "/library.book.name", - ), - ( - "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", - "/library.book.author.first_name", - ), - ( - "{ library(index: 0) { book { ...MyFragment } magazine { ...MagFragment } } } fragment MyFragment on Book { author { first_name } } fragment MagFragment on Magazine { name }", - "/library", - ), -] - - -@dt_enabled -@pytest.mark.parametrize("query,expected_path", _test_queries) -def test_deepest_unique_path(app, graphql_run, query, expected_path): - if expected_path == "/error": - txn_name = "_target_application:resolve_error" - else: - txn_name = "query/%s" % expected_path - - @validate_transaction_metrics( - txn_name, - "GraphQL", - background_task=True, - ) - @background_task() - def _test(): - ok, response = graphql_run(app, query) - if "error" not in query: - assert ok and not response.get("errors") - - _test() - - -@validate_transaction_count(0) -@background_task() -def test_ignored_introspection_transactions(app, graphql_run): - ok, response = graphql_run(app, "{ __schema { types { name } } }") - assert ok and not response.get("errors") + assert version is not None + return "Ariadne", version, target_application, is_background, schema_type, extra_spans diff --git a/tests/framework_ariadne/test_application_async.py b/tests/framework_ariadne/test_application_async.py deleted file mode 100644 index 8e46752f20..0000000000 --- a/tests/framework_ariadne/test_application_async.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2010 New Relic, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import asyncio - -import pytest -from testing_support.fixtures import dt_enabled, validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events - -from newrelic.api.background_task import background_task - - -@pytest.fixture(scope="session") -def graphql_run_async(): - """Wrapper function to simulate framework_graphql test behavior.""" - - def execute(schema, query, *args, **kwargs): - from ariadne import graphql - - return graphql(schema, {"query": query}, *args, **kwargs) - - return execute - - -@dt_enabled -def test_query_and_mutation_async(app, graphql_run_async): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/Ariadne/None", 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Ariadne/storage", 1), - ("GraphQL/resolve/Ariadne/storage_add", 1), - ("GraphQL/operation/Ariadne/query//storage", 1), - ("GraphQL/operation/Ariadne/mutation//storage_add.string", 1), - ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/Ariadne/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/Ariadne/allOther", 2), - ] + _test_mutation_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "StorageAdd", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - async def coro(): - ok, response = await graphql_run_async(app, 'mutation { storage_add(string: "abc") { string } }') - assert ok and not response.get("errors") - ok, response = await graphql_run_async(app, "query { storage }") - assert ok and not response.get("errors") - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.get("data")) - assert "abc" in str(response.get("data")) - - loop = asyncio.new_event_loop() - loop.run_until_complete(coro()) - - _test() diff --git a/tests/framework_ariadne/test_asgi.py b/tests/framework_ariadne/test_asgi.py deleted file mode 100644 index 6275e781f4..0000000000 --- a/tests/framework_ariadne/test_asgi.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright 2010 New Relic, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import json - -import pytest -from testing_support.asgi_testing import AsgiTest -from testing_support.fixtures import dt_enabled, validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events - - -@pytest.fixture(scope="session") -def graphql_asgi_run(): - """Wrapper function to simulate framework_graphql test behavior.""" - from _target_application import _target_asgi_application - - app = AsgiTest(_target_asgi_application) - - def execute(query): - return app.make_request( - "POST", "/", headers={"Content-Type": "application/json"}, body=json.dumps({"query": query}) - ) - - return execute - - -@dt_enabled -def test_query_and_mutation_asgi(graphql_asgi_run): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/Ariadne/None", 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Ariadne/storage_add", 1), - ("GraphQL/operation/Ariadne/mutation//storage_add.string", 1), - ] - _test_query_scoped_metrics = [ - ("GraphQL/resolve/Ariadne/storage", 1), - ("GraphQL/operation/Ariadne/query//storage", 1), - ] - _test_unscoped_metrics = [ - ("WebTransaction", 1), - ("GraphQL/all", 1), - ("GraphQL/Ariadne/all", 1), - ("GraphQL/allWeb", 1), - ("GraphQL/Ariadne/allWeb", 1), - ] - _test_mutation_unscoped_metrics = _test_unscoped_metrics + _test_mutation_scoped_metrics - _test_query_unscoped_metrics = _test_unscoped_metrics + _test_query_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "StorageAdd", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_query_scoped_metrics, - rollup_metrics=_test_query_unscoped_metrics + FRAMEWORK_METRICS, - ) - @validate_transaction_metrics( - "mutation//storage_add.string", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - index=-2, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes, index=-2) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes, index=-2) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - def _test(): - response = graphql_asgi_run('mutation { storage_add(string: "abc") { string } }') - assert response.status == 200 - response = json.loads(response.body.decode("utf-8")) - assert not response.get("errors") - - response = graphql_asgi_run("query { storage }") - assert response.status == 200 - response = json.loads(response.body.decode("utf-8")) - assert not response.get("errors") - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.get("data")) - assert "abc" in str(response.get("data")) - - _test() diff --git a/tests/framework_ariadne/test_wsgi.py b/tests/framework_ariadne/test_wsgi.py deleted file mode 100644 index 2c11276ed0..0000000000 --- a/tests/framework_ariadne/test_wsgi.py +++ /dev/null @@ -1,114 +0,0 @@ -# Copyright 2010 New Relic, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import pytest -import webtest -from testing_support.fixtures import dt_enabled, validate_transaction_metrics -from testing_support.validators.validate_span_events import validate_span_events - - -@pytest.fixture(scope="session") -def graphql_wsgi_run(): - """Wrapper function to simulate framework_graphql test behavior.""" - from _target_application import _target_wsgi_application - - app = webtest.TestApp(_target_wsgi_application) - - def execute(query): - return app.post_json("/", {"query": query}) - - return execute - - -@dt_enabled -def test_query_and_mutation_wsgi(graphql_wsgi_run): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/Ariadne/None", 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Ariadne/storage_add", 1), - ("GraphQL/operation/Ariadne/mutation//storage_add.string", 1), - ] - _test_query_scoped_metrics = [ - ("GraphQL/resolve/Ariadne/storage", 1), - ("GraphQL/operation/Ariadne/query//storage", 1), - ] - _test_unscoped_metrics = [ - ("WebTransaction", 1), - ("Python/WSGI/Response", 1), - ("GraphQL/all", 1), - ("GraphQL/Ariadne/all", 1), - ("GraphQL/allWeb", 1), - ("GraphQL/Ariadne/allWeb", 1), - ] - _test_mutation_unscoped_metrics = _test_unscoped_metrics + _test_mutation_scoped_metrics - _test_query_unscoped_metrics = _test_unscoped_metrics + _test_query_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "StorageAdd", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_query_scoped_metrics, - rollup_metrics=_test_query_unscoped_metrics + FRAMEWORK_METRICS, - ) - @validate_transaction_metrics( - "mutation//storage_add.string", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - index=-2, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes, index=-2) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes, index=-2) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - def _test(): - response = graphql_wsgi_run('mutation { storage_add(string: "abc") { string } }') - assert response.status_code == 200 - response = response.json_body - assert not response.get("errors") - - response = graphql_wsgi_run("query { storage }") - assert response.status_code == 200 - response = response.json_body - assert not response.get("errors") - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.get("data")) - assert "abc" in str(response.get("data")) - - _test() diff --git a/tests/framework_graphql/conftest.py b/tests/framework_graphql/conftest.py index 9aa8b65404..1b04348ea1 100644 --- a/tests/framework_graphql/conftest.py +++ b/tests/framework_graphql/conftest.py @@ -52,7 +52,7 @@ def target_application(request): pytest.skip("Unsupported combination.") return - return "GraphQL", None, app, True, request.param.split("-")[1] + return "GraphQL", None, app, True, request.param.split("-")[1], 0 if six.PY2: diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index b620af2671..6566ff0f04 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -124,7 +124,7 @@ def _graphql_base_rollup_metrics(framework, version, background_task=True): def test_basic(target_application): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application @validate_transaction_metrics( "query//hello", @@ -142,7 +142,7 @@ def _test(): @dt_enabled def test_query_and_mutation(target_application, is_graphql_2): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application type_annotation = "!" if framework == "Strawberry" else "" @@ -213,7 +213,7 @@ def _query(): @pytest.mark.parametrize("middleware", example_middleware) @dt_enabled def test_middleware(target_application, middleware): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application name = "%s:%s" % (middleware.__module__, middleware.__name__) if "async" in name: @@ -221,13 +221,17 @@ def test_middleware(target_application, middleware): pytest.skip("Async middleware not supported in sync applications.") _test_middleware_metrics = [ - ("GraphQL/operation/GraphQL/query//hello", 1), - ("GraphQL/resolve/GraphQL/hello", 1), + ("GraphQL/operation/%s/query//hello" % framework, 1), + ("GraphQL/resolve/%s/hello" % framework, 1), ("Function/%s" % name, 1), ] + # Span count 5: Transaction, Operation, Middleware, and 1 Resolver and Resolver Function + span_count = 5 + extra_spans + @validate_code_level_metrics(*name.split(":")) @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_hello") + @validate_span_events(count=span_count) @validate_transaction_metrics( "query//hello", "GraphQL", @@ -235,8 +239,6 @@ def test_middleware(target_application, middleware): rollup_metrics=_test_middleware_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), background_task=is_bg, ) - # Span count 5: Transaction, Operation, Middleware, and 1 Resolver and Resolver Function - @validate_span_events(count=5) @conditional_decorator(background_task(), is_bg) def _test(): response = target_application("{ hello }", middleware=[middleware]) @@ -248,7 +250,7 @@ def _test(): @pytest.mark.parametrize("middleware", error_middleware) @dt_enabled def test_exception_in_middleware(target_application, middleware): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application query = "query MyQuery { error_middleware }" field = "error_middleware" @@ -302,7 +304,7 @@ def _test(): @pytest.mark.parametrize("field", ("error", "error_non_null")) @dt_enabled def test_exception_in_resolver(target_application, field): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application query = "query MyQuery { %s }" % field txn_name = "_target_schema_%s:resolve_error" % schema_type @@ -357,7 +359,7 @@ def _test(): ], ) def test_exception_in_validation(target_application, is_graphql_2, query, exc_class): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application if "syntax" in query: txn_name = "graphql.language.parser:parse" else: @@ -406,13 +408,18 @@ def _test(): @dt_enabled def test_operation_metrics_and_attrs(target_application): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application operation_metrics = [("GraphQL/operation/%s/query/MyQuery/library" % framework, 1)] operation_attrs = { "graphql.operation.type": "query", "graphql.operation.name": "MyQuery", } + # Span count 16: Transaction, Operation, and 7 Resolvers and Resolver functions + # library, library.name, library.book + # library.book.name and library.book.id for each book resolved (in this case 2) + span_count = 16 + extra_spans # WSGI may add 4 spans, other frameworks may add other amounts + @validate_transaction_metrics( "query/MyQuery/library", "GraphQL", @@ -420,10 +427,7 @@ def test_operation_metrics_and_attrs(target_application): rollup_metrics=operation_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), background_task=is_bg, ) - # Span count 16: Transaction, Operation, and 7 Resolvers and Resolver functions - # library, library.name, library.book - # library.book.name and library.book.id for each book resolved (in this case 2) - @validate_span_events(count=16) + @validate_span_events(count=span_count) @validate_span_events(exact_agents=operation_attrs) @conditional_decorator(background_task(), is_bg) def _test(): @@ -434,7 +438,7 @@ def _test(): @dt_enabled def test_field_resolver_metrics_and_attrs(target_application): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application field_resolver_metrics = [("GraphQL/resolve/%s/hello" % framework, 1)] type_annotation = "!" if framework == "Strawberry" else "" @@ -445,6 +449,9 @@ def test_field_resolver_metrics_and_attrs(target_application): "graphql.field.returnType": "String" + type_annotation, } + # Span count 4: Transaction, Operation, and 1 Resolver and Resolver function + span_count = 4 + extra_spans # WSGI may add 4 spans, other frameworks may add other amounts + @validate_transaction_metrics( "query//hello", "GraphQL", @@ -452,8 +459,7 @@ def test_field_resolver_metrics_and_attrs(target_application): rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), background_task=is_bg, ) - # Span count 4: Transaction, Operation, and 1 Resolver and Resolver function - @validate_span_events(count=4) + @validate_span_events(count=span_count) @validate_span_events(exact_agents=graphql_attrs) @conditional_decorator(background_task(), is_bg) def _test(): @@ -482,7 +488,7 @@ def _test(): @dt_enabled @pytest.mark.parametrize("query,obfuscated", _test_queries) def test_query_obfuscation(target_application, query, obfuscated): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application graphql_attrs = {"graphql.operation.query": obfuscated} if callable(query): @@ -539,7 +545,7 @@ def _test(): @dt_enabled @pytest.mark.parametrize("query,expected_path", _test_queries) def test_deepest_unique_path(target_application, query, expected_path): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application if expected_path == "/error": txn_name = "_target_schema_%s:resolve_error" % schema_type else: @@ -558,7 +564,7 @@ def _test(): def test_ignored_introspection_transactions(target_application): - framework, version, target_application, is_bg, schema_type = target_application + framework, version, target_application, is_bg, schema_type, extra_spans = target_application @validate_transaction_count(0) @background_task() diff --git a/tests/framework_strawberry/test_application.py b/tests/framework_strawberry/test_application.py index be17465162..c705423e0f 100644 --- a/tests/framework_strawberry/test_application.py +++ b/tests/framework_strawberry/test_application.py @@ -31,5 +31,5 @@ def target_application(request): schema_type = request.param.split("-")[1] assert version is not None - return "Strawberry", version, target_application, not is_asgi, schema_type + return "Strawberry", version, target_application, not is_asgi, schema_type, 0 From a2889a6926d84dd744f3fdb44c4013b6f176b01e Mon Sep 17 00:00:00 2001 From: Timothy Pansino <11214426+TimPansino@users.noreply.github.com> Date: Tue, 10 May 2022 12:58:41 -0700 Subject: [PATCH 13/26] Graphene Async Testing (#524) * Graphene Async Testing * Fix missing extra spans numbers * Graphene promise tests * Fix py2 imports * Removed unused __init__ * Update code level metrics validator for py2 * Unify graphql testing imports * Fix ariadne imports * Fix other imports * Fix import issues --- .../test_application.py | 2 +- .../component_flask_rest/test_application.py | 4 +- tests/component_graphqlserver/__init__.py | 13 + .../_target_schema_async.py | 184 +++++++ tests/component_graphqlserver/test_graphql.py | 39 +- tests/framework_ariadne/__init__.py | 13 + .../framework_ariadne/_target_application.py | 4 +- tests/framework_ariadne/test_application.py | 2 +- tests/framework_graphene/__init__.py | 13 + .../framework_graphene/_target_application.py | 212 +++---- .../_target_schema_async.py | 72 +++ .../_target_schema_promise.py | 80 +++ .../framework_graphene/_target_schema_sync.py | 162 ++++++ tests/framework_graphene/test_application.py | 517 +----------------- tests/framework_graphql/__init__.py | 13 + .../framework_graphql/_target_application.py | 6 +- .../framework_graphql/_target_schema_async.py | 2 +- .../_target_schema_promise.py | 8 +- tests/framework_graphql/conftest.py | 7 +- tests/framework_graphql/test_application.py | 33 +- tests/framework_strawberry/__init__.py | 13 + .../_target_application.py | 4 +- .../_target_schema_async.py | 5 +- .../framework_strawberry/test_application.py | 2 +- .../test_pika_async_connection_consume.py | 10 +- .../test_pika_blocking_connection_consume.py | 4 +- .../validators/validate_code_level_metrics.py | 6 +- 27 files changed, 710 insertions(+), 720 deletions(-) create mode 100644 tests/component_graphqlserver/__init__.py create mode 100644 tests/component_graphqlserver/_target_schema_async.py create mode 100644 tests/framework_ariadne/__init__.py create mode 100644 tests/framework_graphene/__init__.py create mode 100644 tests/framework_graphene/_target_schema_async.py create mode 100644 tests/framework_graphene/_target_schema_promise.py create mode 100644 tests/framework_graphene/_target_schema_sync.py create mode 100644 tests/framework_graphql/__init__.py create mode 100644 tests/framework_strawberry/__init__.py diff --git a/tests/component_djangorestframework/test_application.py b/tests/component_djangorestframework/test_application.py index 2951e04019..0d0b98d820 100644 --- a/tests/component_djangorestframework/test_application.py +++ b/tests/component_djangorestframework/test_application.py @@ -165,7 +165,7 @@ def _test(): @validate_transaction_errors(errors=[]) @validate_transaction_metrics(_test_api_view_view_name_get, scoped_metrics=_test_api_view_scoped_metrics_get) -@validate_code_level_metrics("urls.WrappedAPIView" if six.PY3 else "urls", "wrapped_view") +@validate_code_level_metrics("urls.WrappedAPIView", "wrapped_view", py2_namespace="urls") def test_api_view_get(target_application): response = target_application.get('/api_view/') response.mustcontain('wrapped_view response') diff --git a/tests/component_flask_rest/test_application.py b/tests/component_flask_rest/test_application.py index 94b6fbc5c5..d0eb417950 100644 --- a/tests/component_flask_rest/test_application.py +++ b/tests/component_flask_rest/test_application.py @@ -53,7 +53,7 @@ def application(request): ] -@validate_code_level_metrics("_test_application.create_app." if six.PY3 else "_test_application", "IndexResource") +@validate_code_level_metrics("_test_application.create_app.", "IndexResource", py2_namespace="_test_application") @validate_transaction_errors(errors=[]) @validate_transaction_metrics('_test_application:index', scoped_metrics=_test_application_index_scoped_metrics) @@ -80,7 +80,7 @@ def test_application_index(application): def test_application_raises(exception, status_code, ignore_status_code, propagate_exceptions, application): - @validate_code_level_metrics("_test_application.create_app." if six.PY3 else "_test_application", "ExceptionResource") + @validate_code_level_metrics("_test_application.create_app.", "ExceptionResource", py2_namespace="_test_application") @validate_transaction_metrics('_test_application:exception', scoped_metrics=_test_application_raises_scoped_metrics) def _test(): diff --git a/tests/component_graphqlserver/__init__.py b/tests/component_graphqlserver/__init__.py new file mode 100644 index 0000000000..8030baccf7 --- /dev/null +++ b/tests/component_graphqlserver/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/component_graphqlserver/_target_schema_async.py b/tests/component_graphqlserver/_target_schema_async.py new file mode 100644 index 0000000000..c48be21269 --- /dev/null +++ b/tests/component_graphqlserver/_target_schema_async.py @@ -0,0 +1,184 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from graphql import ( + GraphQLArgument, + GraphQLField, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + GraphQLUnionType, +) + +from ._target_schema_sync import books, libraries, magazines + +storage = [] + + +async def resolve_library(parent, info, index): + return libraries[index] + + +async def resolve_storage_add(parent, info, string): + storage.append(string) + return string + + +async def resolve_storage(parent, info): + return [storage.pop()] + + +async def resolve_search(parent, info, contains): + search_books = [b for b in books if contains in b["name"]] + search_magazines = [m for m in magazines if contains in m["name"]] + return search_books + search_magazines + + +Author = GraphQLObjectType( + "Author", + { + "first_name": GraphQLField(GraphQLString), + "last_name": GraphQLField(GraphQLString), + }, +) + +Book = GraphQLObjectType( + "Book", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "isbn": GraphQLField(GraphQLString), + "author": GraphQLField(Author), + "branch": GraphQLField(GraphQLString), + }, +) + +Magazine = GraphQLObjectType( + "Magazine", + { + "id": GraphQLField(GraphQLInt), + "name": GraphQLField(GraphQLString), + "issue": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + }, +) + + +Library = GraphQLObjectType( + "Library", + { + "id": GraphQLField(GraphQLInt), + "branch": GraphQLField(GraphQLString), + "book": GraphQLField(GraphQLList(Book)), + "magazine": GraphQLField(GraphQLList(Magazine)), + }, +) + +Storage = GraphQLList(GraphQLString) + + +async def resolve_hello(root, info): + return "Hello!" + + +async def resolve_echo(root, info, echo): + return echo + + +async def resolve_error(root, info): + raise RuntimeError("Runtime Error!") + + +try: + hello_field = GraphQLField(GraphQLString, resolver=resolve_hello) + library_field = GraphQLField( + Library, + resolver=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolver=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolver=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolver=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolver=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolver=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolver=resolve_hello) +except TypeError: + hello_field = GraphQLField(GraphQLString, resolve=resolve_hello) + library_field = GraphQLField( + Library, + resolve=resolve_library, + args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, + ) + search_field = GraphQLField( + GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + echo_field = GraphQLField( + GraphQLString, + resolve=resolve_echo, + args={"echo": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + storage_field = GraphQLField( + Storage, + resolve=resolve_storage, + ) + storage_add_field = GraphQLField( + GraphQLString, + resolve=resolve_storage_add, + args={"string": GraphQLArgument(GraphQLNonNull(GraphQLString))}, + ) + error_field = GraphQLField(GraphQLString, resolve=resolve_error) + error_non_null_field = GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_error) + error_middleware_field = GraphQLField(GraphQLString, resolve=resolve_hello) + +query = GraphQLObjectType( + name="Query", + fields={ + "hello": hello_field, + "library": library_field, + "search": search_field, + "echo": echo_field, + "storage": storage_field, + "error": error_field, + "error_non_null": error_non_null_field, + "error_middleware": error_middleware_field, + }, +) + +mutation = GraphQLObjectType( + name="Mutation", + fields={ + "storage_add": storage_add_field, + }, +) + +target_schema = GraphQLSchema(query=query, mutation=mutation) diff --git a/tests/component_graphqlserver/test_graphql.py b/tests/component_graphqlserver/test_graphql.py index f361e2193a..f22245d840 100644 --- a/tests/component_graphqlserver/test_graphql.py +++ b/tests/component_graphqlserver/test_graphql.py @@ -36,38 +36,7 @@ def is_graphql_2(): @pytest.fixture(scope="session", params=("Sanic", "Flask")) def target_application(request): - import _test_graphql - framework = request.param - version = importlib.import_module(framework.lower()).__version__ - - return framework, version, _test_graphql.target_application[framework] - - -def example_middleware(next, root, info, **args): #pylint: disable=W0622 - return_value = next(root, info, **args) - return return_value - - -def error_middleware(next, root, info, **args): #pylint: disable=W0622 - raise RuntimeError("Runtime Error!") - - -_runtime_error_name = callable_name(RuntimeError) -_test_runtime_error = [(_runtime_error_name, "Runtime Error!")] -_graphql_base_rollup_metrics = [ - ("GraphQL/all", 1), - ("GraphQL/allWeb", 1), - ("GraphQL/GraphQLServer/all", 1), - ("GraphQL/GraphQLServer/allWeb", 1), -] -_view_metrics = {"Sanic": "Function/graphql_server.sanic.graphqlview:GraphQLView.post", "Flask": "Function/graphql_server.flask.graphqlview:graphql"} - - -def test_basic(target_application): - framework, version, target_application = target_application - from graphql import __version__ as graphql_version - from graphql_server import __version__ as graphql_server_version - + from . import _test_graphql framework = request.param version = importlib.import_module(framework.lower()).__version__ @@ -216,7 +185,7 @@ def test_middleware(target_application): _test_middleware_metrics = [ ("GraphQL/operation/GraphQLServer/query//hello", 1), ("GraphQL/resolve/GraphQLServer/hello", 1), - ("Function/test_graphql:example_middleware", 1), + ("Function/component_graphqlserver.test_graphql:example_middleware", 1), ] # Base span count 6: Transaction, View, Operation, Middleware, and 1 Resolver and Resolver function @@ -250,7 +219,7 @@ def test_exception_in_middleware(target_application): _test_exception_rollup_metrics = [ ("Errors/all", 1), ("Errors/allWeb", 1), - ("Errors/WebTransaction/GraphQL/test_graphql:error_middleware", 1), + ("Errors/WebTransaction/GraphQL/component_graphqlserver.test_graphql:error_middleware", 1), ] + _test_exception_scoped_metrics # Attributes @@ -267,7 +236,7 @@ def test_exception_in_middleware(target_application): } @validate_transaction_metrics( - "test_graphql:error_middleware", + "component_graphqlserver.test_graphql:error_middleware", "GraphQL", scoped_metrics=_test_exception_scoped_metrics, rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, diff --git a/tests/framework_ariadne/__init__.py b/tests/framework_ariadne/__init__.py new file mode 100644 index 0000000000..8030baccf7 --- /dev/null +++ b/tests/framework_ariadne/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/framework_ariadne/_target_application.py b/tests/framework_ariadne/_target_application.py index bf6ef75c8f..b0e19ac0d6 100644 --- a/tests/framework_ariadne/_target_application.py +++ b/tests/framework_ariadne/_target_application.py @@ -17,8 +17,8 @@ import json import pytest -from _target_schema_sync import target_schema as target_schema_sync, target_asgi_application as target_asgi_application_sync, target_wsgi_application as target_wsgi_application_sync -from _target_schema_async import target_schema as target_schema_async, target_asgi_application as target_asgi_application_async +from ._target_schema_sync import target_schema as target_schema_sync, target_asgi_application as target_asgi_application_sync, target_wsgi_application as target_wsgi_application_sync +from ._target_schema_async import target_schema as target_schema_async, target_asgi_application as target_asgi_application_async from graphql import MiddlewareManager diff --git a/tests/framework_ariadne/test_application.py b/tests/framework_ariadne/test_application.py index 40c63bf769..fd294ac92c 100644 --- a/tests/framework_ariadne/test_application.py +++ b/tests/framework_ariadne/test_application.py @@ -17,7 +17,7 @@ @pytest.fixture(scope="session", params=["sync-sync", "async-sync", "async-async", "wsgi-sync", "asgi-sync", "asgi-async"]) def target_application(request): - from _target_application import target_application + from ._target_application import target_application target_application = target_application[request.param] try: diff --git a/tests/framework_graphene/__init__.py b/tests/framework_graphene/__init__.py new file mode 100644 index 0000000000..8030baccf7 --- /dev/null +++ b/tests/framework_graphene/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/framework_graphene/_target_application.py b/tests/framework_graphene/_target_application.py index 50acc776f5..22d18897af 100644 --- a/tests/framework_graphene/_target_application.py +++ b/tests/framework_graphene/_target_application.py @@ -11,150 +11,84 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from graphene import Field, Int, List -from graphene import Mutation as GrapheneMutation -from graphene import NonNull, ObjectType, Schema, String, Union - - -class Author(ObjectType): - first_name = String() - last_name = String() - - -class Book(ObjectType): - id = Int() - name = String() - isbn = String() - author = Field(Author) - branch = String() - - -class Magazine(ObjectType): - id = Int() - name = String() - issue = Int() - branch = String() - - -class Item(Union): - class Meta: - types = (Book, Magazine) - - -class Library(ObjectType): - id = Int() - branch = String() - magazine = Field(List(Magazine)) - book = Field(List(Book)) - - -Storage = List(String) - - -authors = [ - Author( - first_name="New", - last_name="Relic", - ), - Author( - first_name="Bob", - last_name="Smith", - ), - Author( - first_name="Leslie", - last_name="Jones", - ), -] +from graphql import __version__ as version +from newrelic.packages import six -books = [ - Book( - id=1, - name="Python Agent: The Book", - isbn="a-fake-isbn", - author=authors[0], - branch="riverside", - ), - Book( - id=2, - name="Ollies for O11y: A Sk8er's Guide to Observability", - isbn="a-second-fake-isbn", - author=authors[1], - branch="downtown", - ), - Book( - id=3, - name="[Redacted]", - isbn="a-third-fake-isbn", - author=authors[2], - branch="riverside", - ), -] +from ._target_schema_sync import target_schema as target_schema_sync -magazines = [ - Magazine(id=1, name="Reli Updates Weekly", issue=1, branch="riverside"), - Magazine(id=2, name="Reli Updates Weekly", issue=2, branch="downtown"), - Magazine(id=3, name="Node Weekly", issue=1, branch="riverside"), -] +is_graphql_2 = int(version.split(".")[0]) == 2 -libraries = ["riverside", "downtown"] -libraries = [ - Library( - id=i + 1, - branch=branch, - magazine=[m for m in magazines if m.branch == branch], - book=[b for b in books if b.branch == branch], - ) - for i, branch in enumerate(libraries) -] -storage = [] +def check_response(query, response): + if isinstance(query, str) and "error" not in query: + assert not response.errors, response + assert response.data + else: + assert response.errors, response -class StorageAdd(GrapheneMutation): - class Arguments: - string = String(required=True) +def run_sync(schema): + def _run_sync(query, middleware=None): + response = schema.execute(query, middleware=middleware) + check_response(query, response) - string = String() - - def mutate(self, info, string): - storage.append(string) - return String(string=string) - - -class Query(ObjectType): - library = Field(Library, index=Int(required=True)) - hello = String() - search = Field(List(Item), contains=String(required=True)) - echo = Field(String, echo=String(required=True)) - storage = Storage - error = String() - - def resolve_library(self, info, index): - return libraries[index] - - def resolve_storage(self, info): - return storage - - def resolve_search(self, info, contains): - search_books = [b for b in books if contains in b.name] - search_magazines = [m for m in magazines if contains in m.name] - return search_books + search_magazines - - def resolve_hello(self, info): - return "Hello!" - - def resolve_echo(self, info, echo): - return echo - - def resolve_error(self, info): - raise RuntimeError("Runtime Error!") - - error_non_null = Field(NonNull(String), resolver=resolve_error) - - -class Mutation(ObjectType): - storage_add = StorageAdd.Field() - - -_target_application = Schema(query=Query, mutation=Mutation, auto_camelcase=False) + return response.data + return _run_sync + + +def run_async(schema): + import asyncio + def _run_async(query, middleware=None): + loop = asyncio.get_event_loop() + response = loop.run_until_complete(schema.execute_async(query, middleware=middleware)) + check_response(query, response) + + return response.data + return _run_async + +def run_promise(schema): + def _run_promise(query, middleware=None): + response = schema.execute(query, middleware=middleware, return_promise=True).get() + check_response(query, response) + + return response.data + return _run_promise + + +def run_promise(schema, scheduler): + from graphql import graphql + from promise import set_default_scheduler + + def _run_promise(query, middleware=None): + set_default_scheduler(scheduler) + + promise = graphql(schema, query, middleware=middleware, return_promise=True) + response = promise.get() + + check_response(query, response) + + return response.data + + return _run_promise + + +target_application = { + "sync-sync": run_sync(target_schema_sync), +} + +if is_graphql_2: + from ._target_schema_promise import target_schema as target_schema_promise + from promise.schedulers.immediate import ImmediateScheduler + + if six.PY3: + from promise.schedulers.asyncio import AsyncioScheduler as AsyncScheduler + else: + from promise.schedulers.thread import ThreadScheduler as AsyncScheduler + + target_application["sync-promise"] = run_promise(target_schema_promise, ImmediateScheduler()) + target_application["async-promise"] = run_promise(target_schema_promise, AsyncScheduler()) +elif six.PY3: + from ._target_schema_async import target_schema as target_schema_async + target_application["async-sync"] = run_async(target_schema_sync) + target_application["async-async"] = run_async(target_schema_async) diff --git a/tests/framework_graphene/_target_schema_async.py b/tests/framework_graphene/_target_schema_async.py new file mode 100644 index 0000000000..39905f2f9e --- /dev/null +++ b/tests/framework_graphene/_target_schema_async.py @@ -0,0 +1,72 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from graphene import Field, Int, List +from graphene import Mutation as GrapheneMutation +from graphene import NonNull, ObjectType, Schema, String, Union + +from ._target_schema_sync import Author, Book, Magazine, Item, Library, Storage, authors, books, magazines, libraries + + +storage = [] + + +async def resolve_library(self, info, index): + return libraries[index] + +async def resolve_storage(self, info): + return [storage.pop()] + +async def resolve_search(self, info, contains): + search_books = [b for b in books if contains in b.name] + search_magazines = [m for m in magazines if contains in m.name] + return search_books + search_magazines + +async def resolve_hello(self, info): + return "Hello!" + +async def resolve_echo(self, info, echo): + return echo + +async def resolve_error(self, info): + raise RuntimeError("Runtime Error!") + +async def resolve_storage_add(self, info, string): + storage.append(string) + return StorageAdd(string=string) + + +class StorageAdd(GrapheneMutation): + class Arguments: + string = String(required=True) + + string = String() + mutate = resolve_storage_add + + +class Query(ObjectType): + library = Field(Library, index=Int(required=True), resolver=resolve_library) + hello = String(resolver=resolve_hello) + search = Field(List(Item), contains=String(required=True), resolver=resolve_search) + echo = Field(String, echo=String(required=True), resolver=resolve_echo) + storage = Field(Storage, resolver=resolve_storage) + error = String(resolver=resolve_error) + error_non_null = Field(NonNull(String), resolver=resolve_error) + error_middleware = String(resolver=resolve_hello) + + +class Mutation(ObjectType): + storage_add = StorageAdd.Field() + + +target_schema = Schema(query=Query, mutation=Mutation, auto_camelcase=False) diff --git a/tests/framework_graphene/_target_schema_promise.py b/tests/framework_graphene/_target_schema_promise.py new file mode 100644 index 0000000000..905f47a0b4 --- /dev/null +++ b/tests/framework_graphene/_target_schema_promise.py @@ -0,0 +1,80 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from graphene import Field, Int, List +from graphene import Mutation as GrapheneMutation +from graphene import NonNull, ObjectType, Schema, String, Union +from promise import promisify + +from ._target_schema_sync import Author, Book, Magazine, Item, Library, Storage, authors, books, magazines, libraries + + +storage = [] + + +@promisify +def resolve_library(self, info, index): + return libraries[index] + +@promisify +def resolve_storage(self, info): + return [storage.pop()] + +@promisify +def resolve_search(self, info, contains): + search_books = [b for b in books if contains in b.name] + search_magazines = [m for m in magazines if contains in m.name] + return search_books + search_magazines + +@promisify +def resolve_hello(self, info): + return "Hello!" + +@promisify +def resolve_echo(self, info, echo): + return echo + +@promisify +def resolve_error(self, info): + raise RuntimeError("Runtime Error!") + +@promisify +def resolve_storage_add(self, info, string): + storage.append(string) + return StorageAdd(string=string) + + +class StorageAdd(GrapheneMutation): + class Arguments: + string = String(required=True) + + string = String() + mutate = resolve_storage_add + + +class Query(ObjectType): + library = Field(Library, index=Int(required=True), resolver=resolve_library) + hello = String(resolver=resolve_hello) + search = Field(List(Item), contains=String(required=True), resolver=resolve_search) + echo = Field(String, echo=String(required=True), resolver=resolve_echo) + storage = Field(Storage, resolver=resolve_storage) + error = String(resolver=resolve_error) + error_non_null = Field(NonNull(String), resolver=resolve_error) + error_middleware = String(resolver=resolve_hello) + + +class Mutation(ObjectType): + storage_add = StorageAdd.Field() + + +target_schema = Schema(query=Query, mutation=Mutation, auto_camelcase=False) diff --git a/tests/framework_graphene/_target_schema_sync.py b/tests/framework_graphene/_target_schema_sync.py new file mode 100644 index 0000000000..b591790657 --- /dev/null +++ b/tests/framework_graphene/_target_schema_sync.py @@ -0,0 +1,162 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from graphene import Field, Int, List +from graphene import Mutation as GrapheneMutation +from graphene import NonNull, ObjectType, Schema, String, Union + + +class Author(ObjectType): + first_name = String() + last_name = String() + + +class Book(ObjectType): + id = Int() + name = String() + isbn = String() + author = Field(Author) + branch = String() + + +class Magazine(ObjectType): + id = Int() + name = String() + issue = Int() + branch = String() + + +class Item(Union): + class Meta: + types = (Book, Magazine) + + +class Library(ObjectType): + id = Int() + branch = String() + magazine = Field(List(Magazine)) + book = Field(List(Book)) + + +Storage = List(String) + + +authors = [ + Author( + first_name="New", + last_name="Relic", + ), + Author( + first_name="Bob", + last_name="Smith", + ), + Author( + first_name="Leslie", + last_name="Jones", + ), +] + +books = [ + Book( + id=1, + name="Python Agent: The Book", + isbn="a-fake-isbn", + author=authors[0], + branch="riverside", + ), + Book( + id=2, + name="Ollies for O11y: A Sk8er's Guide to Observability", + isbn="a-second-fake-isbn", + author=authors[1], + branch="downtown", + ), + Book( + id=3, + name="[Redacted]", + isbn="a-third-fake-isbn", + author=authors[2], + branch="riverside", + ), +] + +magazines = [ + Magazine(id=1, name="Reli Updates Weekly", issue=1, branch="riverside"), + Magazine(id=2, name="Reli Updates Weekly", issue=2, branch="downtown"), + Magazine(id=3, name="Node Weekly", issue=1, branch="riverside"), +] + + +libraries = ["riverside", "downtown"] +libraries = [ + Library( + id=i + 1, + branch=branch, + magazine=[m for m in magazines if m.branch == branch], + book=[b for b in books if b.branch == branch], + ) + for i, branch in enumerate(libraries) +] + +storage = [] + + +def resolve_library(self, info, index): + return libraries[index] + +def resolve_storage(self, info): + return [storage.pop()] + +def resolve_search(self, info, contains): + search_books = [b for b in books if contains in b.name] + search_magazines = [m for m in magazines if contains in m.name] + return search_books + search_magazines + +def resolve_hello(self, info): + return "Hello!" + +def resolve_echo(self, info, echo): + return echo + +def resolve_error(self, info): + raise RuntimeError("Runtime Error!") + +def resolve_storage_add(self, info, string): + storage.append(string) + return StorageAdd(string=string) + + +class StorageAdd(GrapheneMutation): + class Arguments: + string = String(required=True) + + string = String() + mutate = resolve_storage_add + + +class Query(ObjectType): + library = Field(Library, index=Int(required=True), resolver=resolve_library) + hello = String(resolver=resolve_hello) + search = Field(List(Item), contains=String(required=True), resolver=resolve_search) + echo = Field(String, echo=String(required=True), resolver=resolve_echo) + storage = Field(Storage, resolver=resolve_storage) + error = String(resolver=resolve_error) + error_non_null = Field(NonNull(String), resolver=resolve_error) + error_middleware = String(resolver=resolve_hello) + + +class Mutation(ObjectType): + storage_add = StorageAdd.Field() + + +target_schema = Schema(query=Query, mutation=Mutation, auto_camelcase=False) diff --git a/tests/framework_graphene/test_application.py b/tests/framework_graphene/test_application.py index b4e8e07394..c4d1f15d6a 100644 --- a/tests/framework_graphene/test_application.py +++ b/tests/framework_graphene/test_application.py @@ -13,507 +13,30 @@ # limitations under the License. import pytest -import six -from testing_support.fixtures import ( - dt_enabled, - validate_transaction_errors, - validate_transaction_metrics, -) -from testing_support.validators.validate_span_events import validate_span_events -from testing_support.validators.validate_transaction_count import ( - validate_transaction_count, -) -from newrelic.api.background_task import background_task -from newrelic.common.object_names import callable_name +from framework_graphql.test_application import * -@pytest.fixture(scope="session") -def is_graphql_2(): - from graphql import __version__ as version +@pytest.fixture(scope="session", params=["sync-sync", "async-sync", "async-async", "sync-promise", "async-promise"]) +def target_application(request): + from ._target_application import target_application - major_version = int(version.split(".")[0]) - return major_version == 2 + target_application = target_application.get(request.param, None) + if target_application is None: + pytest.skip("Unsupported combination.") + return + try: + import graphene + version = graphene.__version__ + except Exception: + import pkg_resources + version = pkg_resources.get_distribution("graphene").version -@pytest.fixture(scope="session") -def graphql_run(): - """Wrapper function to simulate framework_graphql test behavior.""" + param = request.param.split("-") + is_background = param[0] not in {"wsgi", "asgi"} + schema_type = param[1] + extra_spans = 4 if param[0] == "wsgi" else 0 - def execute(schema, *args, **kwargs): - return schema.execute(*args, **kwargs) - - return execute - - -def to_graphql_source(query): - def delay_import(): - try: - from graphql import Source - except ImportError: - # Fallback if Source is not implemented - return query - - from graphql import __version__ as version - - # For graphql2, Source objects aren't acceptable input - major_version = int(version.split(".")[0]) - if major_version == 2: - return query - - return Source(query) - - return delay_import - - -def example_middleware(next, root, info, **args): # pylint: disable=W0622 - return_value = next(root, info, **args) - return return_value - - -def error_middleware(next, root, info, **args): # pylint: disable=W0622 - raise RuntimeError("Runtime Error!") - - -_runtime_error_name = callable_name(RuntimeError) -_test_runtime_error = [(_runtime_error_name, "Runtime Error!")] -_graphql_base_rollup_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 1), - ("GraphQL/allOther", 1), - ("GraphQL/Graphene/all", 1), - ("GraphQL/Graphene/allOther", 1), -] - - -def test_basic(app, graphql_run): - from graphql import __version__ as version - - from newrelic.hooks.framework_graphene import framework_details - - FRAMEWORK_METRICS = [ - ("Python/Framework/Graphene/%s" % framework_details()[1], 1), - ("Python/Framework/GraphQL/%s" % version, 1), - ] - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - rollup_metrics=_graphql_base_rollup_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @background_task() - def _test(): - response = graphql_run(app, "{ hello }") - assert not response.errors - - _test() - - -@dt_enabled -def test_query_and_mutation(app, graphql_run): - from graphql import __version__ as version - - FRAMEWORK_METRICS = [ - ("Python/Framework/GraphQL/%s" % version, 1), - ] - _test_mutation_scoped_metrics = [ - ("GraphQL/resolve/Graphene/storage", 1), - ("GraphQL/resolve/Graphene/storage_add", 1), - ("GraphQL/operation/Graphene/query//storage", 1), - ("GraphQL/operation/Graphene/mutation//storage_add.string", 1), - ] - _test_mutation_unscoped_metrics = [ - ("OtherTransaction/all", 1), - ("GraphQL/all", 2), - ("GraphQL/Graphene/all", 2), - ("GraphQL/allOther", 2), - ("GraphQL/Graphene/allOther", 2), - ] + _test_mutation_scoped_metrics - - _expected_mutation_operation_attributes = { - "graphql.operation.type": "mutation", - "graphql.operation.name": "", - } - _expected_mutation_resolver_attributes = { - "graphql.field.name": "storage_add", - "graphql.field.parentType": "Mutation", - "graphql.field.path": "storage_add", - "graphql.field.returnType": "StorageAdd", - } - _expected_query_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "", - } - _expected_query_resolver_attributes = { - "graphql.field.name": "storage", - "graphql.field.parentType": "Query", - "graphql.field.path": "storage", - "graphql.field.returnType": "[String]", - } - - @validate_transaction_metrics( - "query//storage", - "GraphQL", - scoped_metrics=_test_mutation_scoped_metrics, - rollup_metrics=_test_mutation_unscoped_metrics + FRAMEWORK_METRICS, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) - @background_task() - def _test(): - response = graphql_run(app, 'mutation { storage_add(string: "abc") { string } }') - assert not response.errors - response = graphql_run(app, "query { storage }") - assert not response.errors - - # These are separate assertions because pypy stores 'abc' as a unicode string while other Python versions do not - assert "storage" in str(response.data) - assert "abc" in str(response.data) - - _test() - - -@dt_enabled -def test_middleware(app, graphql_run, is_graphql_2): - _test_middleware_metrics = [ - ("GraphQL/operation/Graphene/query//hello", 1), - ("GraphQL/resolve/Graphene/hello", 1), - ("Function/test_application:example_middleware", 1), - ] - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - scoped_metrics=_test_middleware_metrics, - rollup_metrics=_test_middleware_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 5: Transaction, Operation, Middleware, and 1 Resolver and 1 Resolver Function - @validate_span_events(count=5) - @background_task() - def _test(): - response = graphql_run(app, "{ hello }", middleware=[example_middleware]) - assert not response.errors - assert "Hello!" in str(response.data) - - _test() - - -@dt_enabled -def test_exception_in_middleware(app, graphql_run): - query = "query MyQuery { hello }" - field = "hello" - - # Metrics - _test_exception_scoped_metrics = [ - ("GraphQL/operation/Graphene/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/Graphene/%s" % field, 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/test_application:error_middleware", 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_resolver_attributes = { - "graphql.field.name": field, - "graphql.field.parentType": "Query", - "graphql.field.path": field, - "graphql.field.returnType": "String", - } - _expected_exception_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - "test_application:error_middleware", - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_span_events(exact_agents=_expected_exception_resolver_attributes) - @validate_transaction_errors(errors=_test_runtime_error) - @background_task() - def _test(): - response = graphql_run(app, query, middleware=[error_middleware]) - assert response.errors - - _test() - - -@pytest.mark.parametrize("field", ("error", "error_non_null")) -@dt_enabled -def test_exception_in_resolver(app, graphql_run, field): - query = "query MyQuery { %s }" % field - - if six.PY2: - txn_name = "_target_application:resolve_error" - else: - txn_name = "_target_application:Query.resolve_error" - - # Metrics - _test_exception_scoped_metrics = [ - ("GraphQL/operation/Graphene/query/MyQuery/%s" % field, 1), - ("GraphQL/resolve/Graphene/%s" % field, 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_resolver_attributes = { - "graphql.field.name": field, - "graphql.field.parentType": "Query", - "graphql.field.path": field, - "graphql.field.returnType": "String!" if "non_null" in field else "String", - } - _expected_exception_operation_attributes = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - txn_name, - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_span_events(exact_agents=_expected_exception_resolver_attributes) - @validate_transaction_errors(errors=_test_runtime_error) - @background_task() - def _test(): - response = graphql_run(app, query) - assert response.errors - - _test() - - -@dt_enabled -@pytest.mark.parametrize( - "query,exc_class", - [ - ("query MyQuery { missing_field }", "GraphQLError"), - ("{ syntax_error ", "graphql.error.syntax_error:GraphQLSyntaxError"), - ], -) -def test_exception_in_validation(app, graphql_run, is_graphql_2, query, exc_class): - if "syntax" in query: - txn_name = "graphql.language.parser:parse" - else: - if is_graphql_2: - txn_name = "graphql.validation.validation:validate" - else: - txn_name = "graphql.validation.validate:validate" - - # Import path differs between versions - if exc_class == "GraphQLError": - from graphql.error import GraphQLError - - exc_class = callable_name(GraphQLError) - - _test_exception_scoped_metrics = [ - ("GraphQL/operation/Graphene///", 1), - ] - _test_exception_rollup_metrics = [ - ("Errors/all", 1), - ("Errors/allOther", 1), - ("Errors/OtherTransaction/GraphQL/%s" % txn_name, 1), - ] + _test_exception_scoped_metrics - - # Attributes - _expected_exception_operation_attributes = { - "graphql.operation.type": "", - "graphql.operation.name": "", - "graphql.operation.query": query, - } - - @validate_transaction_metrics( - txn_name, - "GraphQL", - scoped_metrics=_test_exception_scoped_metrics, - rollup_metrics=_test_exception_rollup_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - @validate_span_events(exact_agents=_expected_exception_operation_attributes) - @validate_transaction_errors(errors=[exc_class]) - @background_task() - def _test(): - response = graphql_run(app, query) - assert response.errors - - _test() - - -@dt_enabled -def test_operation_metrics_and_attrs(app, graphql_run): - operation_metrics = [("GraphQL/operation/Graphene/query/MyQuery/library", 1)] - operation_attrs = { - "graphql.operation.type": "query", - "graphql.operation.name": "MyQuery", - } - - @validate_transaction_metrics( - "query/MyQuery/library", - "GraphQL", - scoped_metrics=operation_metrics, - rollup_metrics=operation_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 16: Transaction, Operation, and 7 Resolvers and Resolver functions - # library, library.name, library.book - # library.book.name and library.book.id for each book resolved (in this case 2) - @validate_span_events(count=16) - @validate_span_events(exact_agents=operation_attrs) - @background_task() - def _test(): - response = graphql_run(app, "query MyQuery { library(index: 0) { branch, book { id, name } } }") - assert not response.errors - - _test() - - -@dt_enabled -def test_field_resolver_metrics_and_attrs(app, graphql_run): - field_resolver_metrics = [("GraphQL/resolve/Graphene/hello", 1)] - graphql_attrs = { - "graphql.field.name": "hello", - "graphql.field.parentType": "Query", - "graphql.field.path": "hello", - "graphql.field.returnType": "String", - } - - @validate_transaction_metrics( - "query//hello", - "GraphQL", - scoped_metrics=field_resolver_metrics, - rollup_metrics=field_resolver_metrics + _graphql_base_rollup_metrics, - background_task=True, - ) - # Span count 4: Transaction, Operation, and 1 Resolver and Resolver function - @validate_span_events(count=4) - @validate_span_events(exact_agents=graphql_attrs) - @background_task() - def _test(): - response = graphql_run(app, "{ hello }") - assert not response.errors - assert "Hello!" in str(response.data) - - _test() - - -_test_queries = [ - ("{ hello }", "{ hello }"), # Basic query extraction - ("{ error }", "{ error }"), # Extract query on field error - (to_graphql_source("{ hello }"), "{ hello }"), # Extract query from Source objects - ("{ library(index: 0) { branch } }", "{ library(index: ?) { branch } }"), # Integers - ('{ echo(echo: "123") }', "{ echo(echo: ?) }"), # Strings with numerics - ('{ echo(echo: "test") }', "{ echo(echo: ?) }"), # Strings - ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Aliases - ('{ TestEcho: echo(echo: "test") }', "{ TestEcho: echo(echo: ?) }"), # Variables - ( # Fragments - '{ ...MyFragment } fragment MyFragment on Query { echo(echo: "test") }', - "{ ...MyFragment } fragment MyFragment on Query { echo(echo: ?) }", - ), -] - - -@dt_enabled -@pytest.mark.parametrize("query,obfuscated", _test_queries) -def test_query_obfuscation(app, graphql_run, query, obfuscated): - graphql_attrs = {"graphql.operation.query": obfuscated} - - if callable(query): - query = query() - - @validate_span_events(exact_agents=graphql_attrs) - @background_task() - def _test(): - response = graphql_run(app, query) - if not isinstance(query, str) or "error" not in query: - assert not response.errors - - _test() - - -_test_queries = [ - ("{ hello }", "/hello"), # Basic query - ("{ error }", "/error"), # Extract deepest path on field error - ('{ echo(echo: "test") }', "/echo"), # Fields with arguments - ( - "{ library(index: 0) { branch, book { isbn branch } } }", - "/library", - ), # Complex Example, 1 level - ( - "{ library(index: 0) { book { author { first_name }} } }", - "/library.book.author.first_name", - ), # Complex Example, 2 levels - ("{ library(index: 0) { id, book { name } } }", "/library.book.name"), # Filtering - ('{ TestEcho: echo(echo: "test") }', "/echo"), # Aliases - ( - '{ search(contains: "A") { __typename ... on Book { name } } }', - "/search.name", - ), # InlineFragment - ( - '{ hello echo(echo: "test") }', - "", - ), # Multiple root selections. (need to decide on final behavior) - # FragmentSpread - ( - "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { name id }", # Fragment filtering - "/library.book.name", - ), - ( - "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", - "/library.book.author.first_name", - ), - ( - "{ library(index: 0) { book { ...MyFragment } magazine { ...MagFragment } } } fragment MyFragment on Book { author { first_name } } fragment MagFragment on Magazine { name }", - "/library", - ), -] - - -@dt_enabled -@pytest.mark.parametrize("query,expected_path", _test_queries) -def test_deepest_unique_path(app, graphql_run, query, expected_path): - if expected_path == "/error": - if six.PY2: - txn_name = "_target_application:resolve_error" - else: - txn_name = "_target_application:Query.resolve_error" - else: - txn_name = "query/%s" % expected_path - - @validate_transaction_metrics( - txn_name, - "GraphQL", - background_task=True, - ) - @background_task() - def _test(): - response = graphql_run(app, query) - if "error" not in query: - assert not response.errors - - _test() - - -@validate_transaction_count(0) -@background_task() -def test_ignored_introspection_transactions(app, graphql_run): - response = graphql_run(app, "{ __schema { types { name } } }") - assert not response.errors + assert version is not None + return "Graphene", version, target_application, is_background, schema_type, extra_spans diff --git a/tests/framework_graphql/__init__.py b/tests/framework_graphql/__init__.py new file mode 100644 index 0000000000..8030baccf7 --- /dev/null +++ b/tests/framework_graphql/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/framework_graphql/_target_application.py b/tests/framework_graphql/_target_application.py index 903c72137e..5ed7d9edd2 100644 --- a/tests/framework_graphql/_target_application.py +++ b/tests/framework_graphql/_target_application.py @@ -18,7 +18,7 @@ from newrelic.packages import six from newrelic.hooks.framework_graphql import is_promise -from _target_schema_sync import target_schema as target_schema_sync +from ._target_schema_sync import target_schema as target_schema_sync is_graphql_2 = int(version.split(".")[0]) == 2 @@ -86,7 +86,7 @@ def _run_promise(query, middleware=None): } if is_graphql_2: - from _target_schema_promise import target_schema as target_schema_promise + from ._target_schema_promise import target_schema as target_schema_promise from promise.schedulers.immediate import ImmediateScheduler if six.PY3: @@ -97,6 +97,6 @@ def _run_promise(query, middleware=None): target_application["sync-promise"] = run_promise(target_schema_promise, ImmediateScheduler()) target_application["async-promise"] = run_promise(target_schema_promise, AsyncScheduler()) elif six.PY3: - from _target_schema_async import target_schema as target_schema_async + from ._target_schema_async import target_schema as target_schema_async target_application["async-sync"] = run_async(target_schema_sync) target_application["async-async"] = run_async(target_schema_async) diff --git a/tests/framework_graphql/_target_schema_async.py b/tests/framework_graphql/_target_schema_async.py index b57c36bffe..1ea417c109 100644 --- a/tests/framework_graphql/_target_schema_async.py +++ b/tests/framework_graphql/_target_schema_async.py @@ -25,7 +25,7 @@ ) try: - from _target_schema_sync import books, libraries, magazines + from ._target_schema_sync import books, libraries, magazines except ImportError: from framework_graphql._target_schema_sync import books, libraries, magazines diff --git a/tests/framework_graphql/_target_schema_promise.py b/tests/framework_graphql/_target_schema_promise.py index ea08639e14..b0bf8cef7c 100644 --- a/tests/framework_graphql/_target_schema_promise.py +++ b/tests/framework_graphql/_target_schema_promise.py @@ -23,12 +23,9 @@ GraphQLString, GraphQLUnionType, ) -from promise import Promise, promisify +from promise import promisify -try: - from _target_schema_sync import books, libraries, magazines -except ImportError: - from framework_graphql._target_schema_sync import books, libraries, magazines +from ._target_schema_sync import books, libraries, magazines storage = [] @@ -49,6 +46,7 @@ def resolve_storage(parent, info): return [storage.pop()] +@promisify def resolve_search(parent, info, contains): search_books = [b for b in books if contains in b["name"]] search_magazines = [m for m in magazines if contains in m["name"]] diff --git a/tests/framework_graphql/conftest.py b/tests/framework_graphql/conftest.py index 1b04348ea1..e46f70f91f 100644 --- a/tests/framework_graphql/conftest.py +++ b/tests/framework_graphql/conftest.py @@ -40,12 +40,9 @@ default_settings=_default_settings, ) -apps = ["sync-sync", "async-sync", "async-async", "sync-promise", "async-promise"] - - -@pytest.fixture(scope="session", params=apps) +@pytest.fixture(scope="session", params=["sync-sync", "async-sync", "async-async", "sync-promise", "async-promise"]) def target_application(request): - from _target_application import target_application + from ._target_application import target_application app = target_application.get(request.param, None) if app is None: diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index 6566ff0f04..8ac499273c 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -144,11 +144,12 @@ def _test(): def test_query_and_mutation(target_application, is_graphql_2): framework, version, target_application, is_bg, schema_type, extra_spans = target_application + mutation_path = "storage_add" if framework != "Graphene" else "storage_add.string" type_annotation = "!" if framework == "Strawberry" else "" _test_mutation_scoped_metrics = [ ("GraphQL/resolve/%s/storage_add" % framework, 1), - ("GraphQL/operation/%s/mutation//storage_add" % framework, 1), + ("GraphQL/operation/%s/mutation//%s" % (framework, mutation_path), 1), ] _test_query_scoped_metrics = [ ("GraphQL/resolve/%s/storage" % framework, 1), @@ -163,7 +164,7 @@ def test_query_and_mutation(target_application, is_graphql_2): "graphql.field.name": "storage_add", "graphql.field.parentType": "Mutation", "graphql.field.path": "storage_add", - "graphql.field.returnType": "String" + type_annotation, + "graphql.field.returnType": ("String" if framework != "Graphene" else "StorageAdd") + type_annotation, } _expected_query_operation_attributes = { "graphql.operation.type": "query", @@ -176,22 +177,28 @@ def test_query_and_mutation(target_application, is_graphql_2): "graphql.field.returnType": "[String%s]%s" % (type_annotation, type_annotation), } - @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_storage_add") + @validate_code_level_metrics("framework_%s._target_schema_%s" % (framework.lower(), schema_type), "resolve_storage_add") + @validate_span_events(exact_agents=_expected_mutation_operation_attributes) + @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) @validate_transaction_metrics( - "mutation//storage_add", + "mutation//%s" % mutation_path, "GraphQL", scoped_metrics=_test_mutation_scoped_metrics, rollup_metrics=_test_mutation_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), background_task=is_bg, ) - @validate_span_events(exact_agents=_expected_mutation_operation_attributes) - @validate_span_events(exact_agents=_expected_mutation_resolver_attributes) @conditional_decorator(background_task(), is_bg) def _mutation(): - response = target_application('mutation { storage_add(string: "abc") }') - assert response["storage_add"] == "abc" + if framework == "Graphene": + query = 'mutation { storage_add(string: "abc") { string } }' + else: + query = 'mutation { storage_add(string: "abc") }' + response = target_application(query) + assert response["storage_add"] == "abc" or response["storage_add"]["string"] == "abc" - @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_storage") + @validate_code_level_metrics("framework_%s._target_schema_%s" % (framework.lower(), schema_type), "resolve_storage") + @validate_span_events(exact_agents=_expected_query_operation_attributes) + @validate_span_events(exact_agents=_expected_query_resolver_attributes) @validate_transaction_metrics( "query//storage", "GraphQL", @@ -199,8 +206,6 @@ def _mutation(): rollup_metrics=_test_query_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), background_task=is_bg, ) - @validate_span_events(exact_agents=_expected_query_operation_attributes) - @validate_span_events(exact_agents=_expected_query_resolver_attributes) @conditional_decorator(background_task(), is_bg) def _query(): response = target_application("query { storage }") @@ -230,7 +235,7 @@ def test_middleware(target_application, middleware): span_count = 5 + extra_spans @validate_code_level_metrics(*name.split(":")) - @validate_code_level_metrics("_target_schema_%s" % schema_type, "resolve_hello") + @validate_code_level_metrics("framework_%s._target_schema_%s" % (framework.lower(), schema_type), "resolve_hello") @validate_span_events(count=span_count) @validate_transaction_metrics( "query//hello", @@ -307,7 +312,7 @@ def test_exception_in_resolver(target_application, field): framework, version, target_application, is_bg, schema_type, extra_spans = target_application query = "query MyQuery { %s }" % field - txn_name = "_target_schema_%s:resolve_error" % schema_type + txn_name = "framework_%s._target_schema_%s:resolve_error" % (framework.lower(), schema_type) # Metrics _test_exception_scoped_metrics = [ @@ -547,7 +552,7 @@ def _test(): def test_deepest_unique_path(target_application, query, expected_path): framework, version, target_application, is_bg, schema_type, extra_spans = target_application if expected_path == "/error": - txn_name = "_target_schema_%s:resolve_error" % schema_type + txn_name = "framework_%s._target_schema_%s:resolve_error" % (framework.lower(), schema_type) else: txn_name = "query/%s" % expected_path diff --git a/tests/framework_strawberry/__init__.py b/tests/framework_strawberry/__init__.py new file mode 100644 index 0000000000..8030baccf7 --- /dev/null +++ b/tests/framework_strawberry/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2010 New Relic, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/framework_strawberry/_target_application.py b/tests/framework_strawberry/_target_application.py index 29737db97d..ec618be5c4 100644 --- a/tests/framework_strawberry/_target_application.py +++ b/tests/framework_strawberry/_target_application.py @@ -17,8 +17,8 @@ import json import pytest -from _target_schema_sync import target_schema as target_schema_sync, target_asgi_application as target_asgi_application_sync -from _target_schema_async import target_schema as target_schema_async, target_asgi_application as target_asgi_application_async +from ._target_schema_sync import target_schema as target_schema_sync, target_asgi_application as target_asgi_application_sync +from ._target_schema_async import target_schema as target_schema_async, target_asgi_application as target_asgi_application_async def run_sync(schema): diff --git a/tests/framework_strawberry/_target_schema_async.py b/tests/framework_strawberry/_target_schema_async.py index 0b4953eb70..397166d4de 100644 --- a/tests/framework_strawberry/_target_schema_async.py +++ b/tests/framework_strawberry/_target_schema_async.py @@ -21,10 +21,7 @@ from strawberry.types.types import Optional from testing_support.asgi_testing import AsgiTest -try: - from _target_schema_sync import Library, Item, Storage, books, magazines, libraries -except ImportError: - from framework_strawberry._target_schema_sync import Library, Item, Storage, books, magazines, libraries +from ._target_schema_sync import Library, Item, Storage, books, magazines, libraries storage = [] diff --git a/tests/framework_strawberry/test_application.py b/tests/framework_strawberry/test_application.py index c705423e0f..76082dee90 100644 --- a/tests/framework_strawberry/test_application.py +++ b/tests/framework_strawberry/test_application.py @@ -17,7 +17,7 @@ @pytest.fixture(scope="session", params=["sync-sync", "async-sync", "async-async", "asgi-sync", "asgi-async"]) def target_application(request): - from _target_application import target_application + from ._target_application import target_application target_application = target_application[request.param] try: diff --git a/tests/messagebroker_pika/test_pika_async_connection_consume.py b/tests/messagebroker_pika/test_pika_async_connection_consume.py index 0ed76503f8..18c999845f 100644 --- a/tests/messagebroker_pika/test_pika_async_connection_consume.py +++ b/tests/messagebroker_pika/test_pika_async_connection_consume.py @@ -76,7 +76,7 @@ def handle_callback_exception(self, *args, **kwargs): @parametrized_connection @pytest.mark.parametrize('callback_as_partial', [True, False]) -@validate_code_level_metrics("test_pika_async_connection_consume" + (".test_async_connection_basic_get_inside_txn." if six.PY3 else ""), "on_message") +@validate_code_level_metrics("test_pika_async_connection_consume.test_async_connection_basic_get_inside_txn.", "on_message", py2_namespace="test_pika_async_connection_consume") @validate_transaction_metrics( ('test_pika_async_connection_consume:' 'test_async_connection_basic_get_inside_txn'), @@ -269,7 +269,7 @@ def on_open_connection(connection): scoped_metrics=_test_select_conn_basic_consume_in_txn_metrics, rollup_metrics=_test_select_conn_basic_consume_in_txn_metrics, background_task=True) -@validate_code_level_metrics("test_pika_async_connection_consume" + (".test_async_connection_basic_consume_inside_txn." if six.PY3 else ""), "on_message") +@validate_code_level_metrics("test_pika_async_connection_consume.test_async_connection_basic_consume_inside_txn.", "on_message", py2_namespace="test_pika_async_connection_consume") @validate_tt_collector_json(message_broker_params=_message_broker_tt_params) @background_task() def test_async_connection_basic_consume_inside_txn(producer, ConnectionClass): @@ -329,8 +329,8 @@ def on_open_connection(connection): scoped_metrics=_test_select_conn_basic_consume_two_exchanges, rollup_metrics=_test_select_conn_basic_consume_two_exchanges, background_task=True) -@validate_code_level_metrics("test_pika_async_connection_consume" + (".test_async_connection_basic_consume_two_exchanges." if six.PY3 else ""), "on_message_1") -@validate_code_level_metrics("test_pika_async_connection_consume" + (".test_async_connection_basic_consume_two_exchanges." if six.PY3 else ""), "on_message_2") +@validate_code_level_metrics("test_pika_async_connection_consume.test_async_connection_basic_consume_two_exchanges.", "on_message_1", py2_namespace="test_pika_async_connection_consume") +@validate_code_level_metrics("test_pika_async_connection_consume.test_async_connection_basic_consume_two_exchanges.", "on_message_2", py2_namespace="test_pika_async_connection_consume") @background_task() def test_async_connection_basic_consume_two_exchanges(producer, producer_2, ConnectionClass): @@ -435,7 +435,7 @@ def on_open_connection(connection): rollup_metrics=_test_select_connection_consume_outside_txn_metrics, background_task=True, group='Message/RabbitMQ/Exchange/%s' % EXCHANGE) -@validate_code_level_metrics("test_pika_async_connection_consume" + (".test_select_connection_basic_consume_outside_transaction." if six.PY3 else ""), "on_message") +@validate_code_level_metrics("test_pika_async_connection_consume.test_select_connection_basic_consume_outside_transaction.", "on_message", py2_namespace="test_pika_async_connection_consume") def test_select_connection_basic_consume_outside_transaction(producer): def on_message(channel, method_frame, header_frame, body): assert hasattr(method_frame, '_nr_start_time') diff --git a/tests/messagebroker_pika/test_pika_blocking_connection_consume.py b/tests/messagebroker_pika/test_pika_blocking_connection_consume.py index 417055bfce..d52fce95a4 100644 --- a/tests/messagebroker_pika/test_pika_blocking_connection_consume.py +++ b/tests/messagebroker_pika/test_pika_blocking_connection_consume.py @@ -133,7 +133,7 @@ def test_basic_get(): @pytest.mark.parametrize('as_partial', [True, False]) -@validate_code_level_metrics("test_pika_blocking_connection_consume" + (".test_blocking_connection_basic_consume_outside_transaction." if six.PY3 else ""), "on_message") +@validate_code_level_metrics("test_pika_blocking_connection_consume.test_blocking_connection_basic_consume_outside_transaction.", "on_message", py2_namespace="test_pika_blocking_connection_consume") @validate_transaction_metrics( _txn_name, scoped_metrics=_test_blocking_conn_basic_consume_no_txn_metrics, @@ -179,7 +179,7 @@ def on_message(channel, method_frame, header_frame, body): @pytest.mark.parametrize('as_partial', [True, False]) -@validate_code_level_metrics("test_pika_blocking_connection_consume" + (".test_blocking_connection_basic_consume_inside_txn." if six.PY3 else ""), "on_message") +@validate_code_level_metrics("test_pika_blocking_connection_consume.test_blocking_connection_basic_consume_inside_txn.", "on_message", py2_namespace="test_pika_blocking_connection_consume") @validate_transaction_metrics( ('test_pika_blocking_connection_consume:' 'test_blocking_connection_basic_consume_inside_txn'), diff --git a/tests/testing_support/validators/validate_code_level_metrics.py b/tests/testing_support/validators/validate_code_level_metrics.py index d5c4b56482..1f99d9d52a 100644 --- a/tests/testing_support/validators/validate_code_level_metrics.py +++ b/tests/testing_support/validators/validate_code_level_metrics.py @@ -12,13 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +from newrelic.packages import six from testing_support.validators.validate_span_events import validate_span_events from testing_support.fixtures import dt_enabled from newrelic.common.object_wrapper import function_wrapper -def validate_code_level_metrics(namespace, function, builtin=False, count=1, index=-1): +def validate_code_level_metrics(namespace, function, py2_namespace=None, builtin=False, count=1, index=-1): """Verify that code level metrics are generated for a callable.""" + if six.PY2 and py2_namespace is not None: + namespace = py2_namespace + if builtin: validator = validate_span_events( exact_agents={"code.function": function, "code.namespace": namespace, "code.filepath": ""}, From 8ce4dcdd4684c2089dd0da947f1515285bf3d55c Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Tue, 27 Dec 2022 09:43:07 -0500 Subject: [PATCH 14/26] Add path to graphql resolver trace node --- newrelic/core/graphql_node.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/newrelic/core/graphql_node.py b/newrelic/core/graphql_node.py index a32e185ee9..f6bdc1c9f8 100644 --- a/newrelic/core/graphql_node.py +++ b/newrelic/core/graphql_node.py @@ -58,9 +58,9 @@ class GraphQLResolverNode(_GraphQLResolverNode, GraphQLNodeMixin): @property def name(self): field_name = self.field_name or "" - product = self.product - - name = 'GraphQL/resolve/%s/%s' % (product, field_name) + field_path = self.field_path or "" + product = self.product or "GraphQL" + name = "/".join(('GraphQL/resolve', product, *field_path.split("."), field_name)) return name From b79a077862cc1e27e31dc6036ec1fcf759820506 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Tue, 27 Dec 2022 10:46:19 -0500 Subject: [PATCH 15/26] Graphene v3 resolver traces fixed --- newrelic/api/graphql_trace.py | 1 + newrelic/core/graphql_node.py | 13 +++----- newrelic/hooks/framework_graphql.py | 19 ++++++++--- tests/framework_graphql/test_application.py | 35 +++++++++++++++++++++ 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/newrelic/api/graphql_trace.py b/newrelic/api/graphql_trace.py index 6863bd73de..b7b6637b42 100644 --- a/newrelic/api/graphql_trace.py +++ b/newrelic/api/graphql_trace.py @@ -182,6 +182,7 @@ def finalize_data(self, *args, **kwargs): def create_node(self): return GraphQLResolverNode( field_name=self.field_name, + field_path=self.field_path, children=self.children, start_time=self.start_time, end_time=self.end_time, diff --git a/newrelic/core/graphql_node.py b/newrelic/core/graphql_node.py index f6bdc1c9f8..ad2fa95ad9 100644 --- a/newrelic/core/graphql_node.py +++ b/newrelic/core/graphql_node.py @@ -26,7 +26,7 @@ 'agent_attributes', 'user_attributes', 'product']) _GraphQLResolverNode = namedtuple('_GraphQLNode', - ['field_name', 'children', 'start_time', 'end_time', 'duration', + ['field_name', 'field_path', 'children', 'start_time', 'end_time', 'duration', 'exclusive', 'guid', 'agent_attributes', 'user_attributes', 'product']) class GraphQLNodeMixin(GenericNodeMixin): @@ -57,10 +57,10 @@ def trace_node(self, stats, root, connections): class GraphQLResolverNode(_GraphQLResolverNode, GraphQLNodeMixin): @property def name(self): - field_name = self.field_name or "" - field_path = self.field_path or "" + field_path = self.field_path or self.field_name or "" product = self.product or "GraphQL" - name = "/".join(('GraphQL/resolve', product, *field_path.split("."), field_name)) + + name = 'GraphQL/resolve/%s/%s' % (product, field_path) return name @@ -69,12 +69,9 @@ def time_metrics(self, stats, root, parent): database node as well as all the child nodes. """ - field_name = self.field_name or "" - product = self.product - # Determine the scoped metric - field_resolver_metric_name = 'GraphQL/resolve/%s/%s' % (product, field_name) + field_resolver_metric_name = self.name yield TimeMetric(name=field_resolver_metric_name, scope=root.path, duration=self.duration, exclusive=self.exclusive) diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index b5af068f03..98a604da83 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -477,10 +477,21 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): field_name = field_asts[0].name.value field_def = parent_type.fields.get(field_name) field_return_type = str(field_def.type) if field_def else "" - if isinstance(field_path, list): - field_path = field_path[0] - else: - field_path = field_path.key + + # Attempt to unpack field path + try: + if isinstance(field_path, list): + field_path = field_path[0] + else: + path = [field_path.key] + field_path = getattr(field_path, "prev", None) + while field_path is not None: + if field_path.typename is not None: + path.insert(0, field_path.key) + field_path = getattr(field_path, "prev", None) + field_path = ".".join(path) + except Exception: + field_path = field_name trace = GraphQLResolverTrace( field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index 8ac499273c..d4328ff9a8 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -140,6 +140,41 @@ def _test(): _test() +def test_resolver_trace(target_application): + framework, version, target_application, is_bg, schema_type, extra_spans = target_application + type_annotation = "!" if framework == "Strawberry" else "" + + _test_scoped_metrics = [ + ("GraphQL/resolve/%s/library" % framework, 1), + ("GraphQL/resolve/%s/library.book" % framework, 1), + ("GraphQL/resolve/%s/library.book.author" % framework, 2), + ("GraphQL/resolve/%s/library.book.author.first_name" % framework, 2), + ("GraphQL/operation/%s/query//library.book.author.first_name" % framework, 1), + ] + _expected_resolver_attributes = { + "graphql.field.name": "first_name", + "graphql.field.parentType": "Author", + "graphql.field.path": "library.book.author.first_name", + "graphql.field.returnType": "String%s" % type_annotation, + } + + @validate_span_events(count=2, exact_agents=_expected_resolver_attributes) + @validate_transaction_metrics( + "query//library.book.author.first_name", + "GraphQL", + scoped_metrics=_test_scoped_metrics, + rollup_metrics=_test_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, + ) + @conditional_decorator(background_task(), is_bg) + def _test(): + response = target_application("{ library(index: 0) { book { author { first_name }} } }") + expected = repr([{'author': {'first_name': 'New'}}, {'author': {'first_name': 'Leslie'}}]) + assert repr(response["library"]["book"]) == expected + + _test() + + @dt_enabled def test_query_and_mutation(target_application, is_graphql_2): framework, version, target_application, is_bg, schema_type, extra_spans = target_application From 412032ee1771e167c410aa383ae509a5e70e900d Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Tue, 27 Dec 2022 10:46:33 -0500 Subject: [PATCH 16/26] Validate span events made more verbose --- tests/testing_support/validators/validate_span_events.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/testing_support/validators/validate_span_events.py b/tests/testing_support/validators/validate_span_events.py index 19fae97eed..7ce204c20f 100644 --- a/tests/testing_support/validators/validate_span_events.py +++ b/tests/testing_support/validators/validate_span_events.py @@ -82,7 +82,7 @@ def stream_capture(wrapped, instance, args, kwargs): captured_events = recorded_span_events.pop(index) mismatches = [] - matching_span_events = 0 + matching_span_events = [] for captured_event in captured_events: if Span and isinstance(captured_event, Span): intrinsics = captured_event.intrinsics @@ -102,19 +102,20 @@ def stream_capture(wrapped, instance, args, kwargs): user_attr_ok = _check_span_attributes(user_attrs, exact_users, expected_users, unexpected_users, mismatches) if intrinsics_ok and agent_attr_ok and user_attr_ok: - matching_span_events += 1 + matching_span_events.append(captured_event) def _span_details(): details = [ - "matching_span_events=%d" % matching_span_events, + "matching_span_event_count=%d" % len(matching_span_events), "count=%d" % count, "mismatches=%s" % mismatches, + "matches=%s" % matching_span_events, "captured_events=%s" % captured_events, ] return "\n".join(details) - assert matching_span_events == count, _span_details() + assert len(matching_span_events) == count, _span_details() return val From 2042998f63c9f16c683b9126c60122f8404f70c3 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Tue, 27 Dec 2022 14:37:22 -0500 Subject: [PATCH 17/26] Graphql v2 trace fixes --- newrelic/hooks/framework_graphql.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index 98a604da83..e8cc8548c2 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -481,7 +481,21 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): # Attempt to unpack field path try: if isinstance(field_path, list): - field_path = field_path[0] + from graphql.type.definition import GraphQLList + + exe_context = args[0] + type_map = exe_context.schema.get_type_map() + path = [] + current_type = type_map["Query"] + for item in field_path: + if isinstance(current_type, GraphQLList): + current_type = current_type.of_type + + item_def = current_type.fields.get(item, None) + if item_def is not None: + path.append(item) + current_type = item_def.type + field_path = ".".join(path) else: path = [field_path.key] field_path = getattr(field_path, "prev", None) From 6eafefb36b6ae67bc7d4ac0b6f790862594ee54a Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Tue, 27 Dec 2022 14:38:35 -0500 Subject: [PATCH 18/26] Update tests for resolver traces --- tests/framework_graphql/test_application.py | 45 +++++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index d4328ff9a8..735b3e9933 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -140,7 +140,8 @@ def _test(): _test() -def test_resolver_trace(target_application): +@pytest.mark.parametrize("query", ("{ library(index: 0) { book { author { first_name }} } }", "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }"), ids=("standard", "fragments")) +def test_resolver_trace(target_application, query): framework, version, target_application, is_bg, schema_type, extra_spans = target_application type_annotation = "!" if framework == "Strawberry" else "" @@ -168,9 +169,45 @@ def test_resolver_trace(target_application): ) @conditional_decorator(background_task(), is_bg) def _test(): - response = target_application("{ library(index: 0) { book { author { first_name }} } }") - expected = repr([{'author': {'first_name': 'New'}}, {'author': {'first_name': 'Leslie'}}]) - assert repr(response["library"]["book"]) == expected + response = target_application(query) + expected = [{'author': {'first_name': 'New'}}, {'author': {'first_name': 'Leslie'}}] + assert response["library"]["book"] == expected + + _test() + + +def test_resolver_trace_path_fragments(target_application): + framework, version, target_application, is_bg, schema_type, extra_spans = target_application + type_annotation = "!" if framework == "Strawberry" else "" + + _test_scoped_metrics = [ + ("GraphQL/resolve/%s/search" % framework, 1), + ("GraphQL/resolve/%s/search" % framework, 1), + ("GraphQL/resolve/%s/search.__typename" % framework, 1), + ("GraphQL/resolve/%s/search.author" % framework, 2), + ("GraphQL/resolve/%s/search.author.first_name" % framework, 2), + ("GraphQL/operation/%s/query//library.book.author.first_name" % framework, 1), + ] + _expected_resolver_attributes = { + "graphql.field.name": "first_name", + "graphql.field.parentType": "Author", + "graphql.field.path": "search.author.first_name", + "graphql.field.returnType": "String%s" % type_annotation, + } + + @validate_span_events(count=2, exact_agents=_expected_resolver_attributes) + @validate_transaction_metrics( + "query//search.author.first_name", + "GraphQL", + scoped_metrics=_test_scoped_metrics, + rollup_metrics=_test_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), + background_task=is_bg, + ) + @conditional_decorator(background_task(), is_bg) + def _test(): + response = target_application('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }') + expected = {'search': [{'__typename': 'Book', 'author': {'first_name': 'New'}}, {'__typename': 'Book', 'author': {'first_name': 'Bob'}}]} + assert response == expected, response _test() From 73cf83f77fca236d63240d98dbdc5452660f288a Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 28 Dec 2022 15:29:35 -0500 Subject: [PATCH 19/26] Fix graphql schema search field --- tests/framework_graphql/_target_schema_async.py | 9 +++++++-- tests/framework_graphql/_target_schema_promise.py | 9 +++++++-- tests/framework_graphql/_target_schema_sync.py | 9 +++++++-- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/tests/framework_graphql/_target_schema_async.py b/tests/framework_graphql/_target_schema_async.py index 1ea417c109..26238348cf 100644 --- a/tests/framework_graphql/_target_schema_async.py +++ b/tests/framework_graphql/_target_schema_async.py @@ -93,6 +93,9 @@ async def resolve_search(parent, info, contains): Storage = GraphQLList(GraphQLString) +item_type_resolver = lambda x, _: "Book" if "isbn" in x else "Magazine" +Item = GraphQLUnionType("Item", (Book, Magazine), resolve_type=item_type_resolver) + async def resolve_hello(root, info): return "Hello!" @@ -114,7 +117,8 @@ async def resolve_error(root, info): args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, ) search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + GraphQLList(Item), + resolver=resolve_search, args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, ) echo_field = GraphQLField( @@ -142,7 +146,8 @@ async def resolve_error(root, info): args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, ) search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + GraphQLList(Item), + resolve=resolve_search, args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, ) echo_field = GraphQLField( diff --git a/tests/framework_graphql/_target_schema_promise.py b/tests/framework_graphql/_target_schema_promise.py index b0bf8cef7c..dafcf9b467 100644 --- a/tests/framework_graphql/_target_schema_promise.py +++ b/tests/framework_graphql/_target_schema_promise.py @@ -95,6 +95,9 @@ def resolve_search(parent, info, contains): Storage = GraphQLList(GraphQLString) +item_type_resolver = lambda x, _: "Book" if "isbn" in x else "Magazine" +Item = GraphQLUnionType("Item", (Book, Magazine), resolve_type=item_type_resolver) + @promisify def resolve_hello(root, info): @@ -119,7 +122,8 @@ def resolve_error(root, info): args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, ) search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + GraphQLList(Item), + resolver=resolve_search, args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, ) echo_field = GraphQLField( @@ -147,7 +151,8 @@ def resolve_error(root, info): args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, ) search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + GraphQLList(Item), + resolve=resolve_search, args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, ) echo_field = GraphQLField( diff --git a/tests/framework_graphql/_target_schema_sync.py b/tests/framework_graphql/_target_schema_sync.py index ddfd8d190a..3fd0a2a149 100644 --- a/tests/framework_graphql/_target_schema_sync.py +++ b/tests/framework_graphql/_target_schema_sync.py @@ -145,6 +145,9 @@ def resolve_search(parent, info, contains): Storage = GraphQLList(GraphQLString) +item_type_resolver = lambda x, _: "Book" if "isbn" in x else "Magazine" +Item = GraphQLUnionType("Item", (Book, Magazine), resolve_type=item_type_resolver) + def resolve_hello(root, info): return "Hello!" @@ -166,7 +169,8 @@ def resolve_error(root, info): args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, ) search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + GraphQLList(Item), + resolver=resolve_search, args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, ) echo_field = GraphQLField( @@ -194,7 +198,8 @@ def resolve_error(root, info): args={"index": GraphQLArgument(GraphQLNonNull(GraphQLInt))}, ) search_field = GraphQLField( - GraphQLList(GraphQLUnionType("Item", (Book, Magazine), resolve_type=resolve_search)), + GraphQLList(Item), + resolve=resolve_search, args={"contains": GraphQLArgument(GraphQLNonNull(GraphQLString))}, ) echo_field = GraphQLField( From d239756af46055bcadb2a4936185ac390a6a1ba0 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 28 Dec 2022 15:33:02 -0500 Subject: [PATCH 20/26] Graphql v2 field path algorithm --- newrelic/hooks/framework_graphql.py | 58 +++++++++++++++------ tests/framework_graphql/test_application.py | 5 +- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index e8cc8548c2..5999c624db 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -452,11 +452,11 @@ def wrap_parse(wrapped, instance, args, kwargs): def bind_resolve_field_v3(parent_type, source, field_nodes, path): - return parent_type, field_nodes, path + return None, parent_type, source, field_nodes, None, path def bind_resolve_field_v2(exe_context, parent_type, source, field_asts, parent_info, field_path): - return parent_type, field_asts, field_path + return exe_context, parent_type, source, field_asts, parent_info, field_path def wrap_resolve_field(wrapped, instance, args, kwargs): @@ -470,7 +470,7 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): bind_resolve_field = bind_resolve_field_v3 try: - parent_type, field_asts, field_path = bind_resolve_field(*args, **kwargs) + execution_context, parent_type, source, field_asts, parent_info, field_path = bind_resolve_field(*args, **kwargs) except TypeError: return wrapped(*args, **kwargs) @@ -481,20 +481,43 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): # Attempt to unpack field path try: if isinstance(field_path, list): - from graphql.type.definition import GraphQLList - - exe_context = args[0] - type_map = exe_context.schema.get_type_map() - path = [] - current_type = type_map["Query"] - for item in field_path: - if isinstance(current_type, GraphQLList): - current_type = current_type.of_type - - item_def = current_type.fields.get(item, None) - if item_def is not None: - path.append(item) - current_type = item_def.type + execution_context = execution_context or instance + operation = getattr(parent_info, "operation", None) + + if getattr(operation, "selection_set", None) is None: + path = field_path + else: + fields = operation.selection_set.selections + fragments = execution_context.fragments + + path = [] + + field_path = deque(field_path) + while field_path: + item = field_path.popleft() + if isinstance(item, int): + # Ignore list indexes + continue + + field_dict = {f.name.value: f for f in fields if not is_fragment(f)} + field_def = field_dict.get(item, None) + if field_def is not None: + path.append(item) + fields = getattr(getattr(field_def, "selection_set", None), "selections", []) + elif item.startswith("__"): + # Instrospection field + path.append(item) + break + else: + field_fragments = [f for f in fields if is_named_fragment(f)] + if len(field_fragments) == 1: + current_fragment = field_fragments[0] + path[-1] += "<%s>" % current_fragment.type_condition.name.value + fields = current_fragment.selection_set.selections + field_path.appendleft(item) # Process item again + else: + raise ValueError() + field_path = ".".join(path) else: path = [field_path.key] @@ -507,6 +530,7 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): except Exception: field_path = field_name + # Set up resolver trace trace = GraphQLResolverTrace( field_name, field_parent_type=parent_type.name, field_return_type=field_return_type, field_path=field_path ) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index 735b3e9933..f293672fe3 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -182,11 +182,10 @@ def test_resolver_trace_path_fragments(target_application): _test_scoped_metrics = [ ("GraphQL/resolve/%s/search" % framework, 1), - ("GraphQL/resolve/%s/search" % framework, 1), - ("GraphQL/resolve/%s/search.__typename" % framework, 1), + ("GraphQL/resolve/%s/search.__typename" % framework, 2), ("GraphQL/resolve/%s/search.author" % framework, 2), ("GraphQL/resolve/%s/search.author.first_name" % framework, 2), - ("GraphQL/operation/%s/query//library.book.author.first_name" % framework, 1), + ("GraphQL/operation/%s/query//search.author.first_name" % framework, 1), ] _expected_resolver_attributes = { "graphql.field.name": "first_name", From af505f4643ef7a4ae26a99c41d0c4569687b798e Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 28 Dec 2022 15:45:54 -0500 Subject: [PATCH 21/26] Additional resolver trace path testing --- tests/framework_graphql/test_application.py | 61 +++++++++++++-------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index f293672fe3..195cdc7fe1 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -140,8 +140,7 @@ def _test(): _test() -@pytest.mark.parametrize("query", ("{ library(index: 0) { book { author { first_name }} } }", "{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }"), ids=("standard", "fragments")) -def test_resolver_trace(target_application, query): +def test_resolver_trace(target_application): framework, version, target_application, is_bg, schema_type, extra_spans = target_application type_annotation = "!" if framework == "Strawberry" else "" @@ -169,34 +168,53 @@ def test_resolver_trace(target_application, query): ) @conditional_decorator(background_task(), is_bg) def _test(): - response = target_application(query) + response = target_application("{ library(index: 0) { book { author { first_name }} } }") expected = [{'author': {'first_name': 'New'}}, {'author': {'first_name': 'Leslie'}}] assert response["library"]["book"] == expected _test() -def test_resolver_trace_path_fragments(target_application): +@pytest.mark.parametrize("query,metric_stubs", [ + ("{ library(index: 0) { book { author { first_name }} } }", [ + ("library", 1), + ("library.book", 1), + ("library.book.author", 2), + ("library.book.author.first_name", 2), + ("query//library.book.author.first_name", 1), + ]), + ("{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", [ + ("library", 1), + ("library.book", 1), + ("library.book.author", 2), + ("library.book.author.first_name", 2), + ("query//library.book.author.first_name", 1), + ]), + ('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }', [ + ("search", 1), + ("search.__typename", 2), + ("search.author", 2), + ("search.author.first_name", 2), + ("query//search.author.first_name", 1), + ]), + ('{ search(contains: "A") { __typename ... on Book { author { first_name } } ... on Magazine { author { first_name } } } }', [ + ("search", 1), + ("search.__typename", 2), + ("search.author", 2), + ("search.author.first_name", 2), + ("query//search.author.first_name", 1), + ]), +], ids=["standard", "named_fragment", "inline_fragment", "multi_inline_fragment"]) +def test_resolver_trace_paths(target_application, query, metric_stubs): framework, version, target_application, is_bg, schema_type, extra_spans = target_application type_annotation = "!" if framework == "Strawberry" else "" - _test_scoped_metrics = [ - ("GraphQL/resolve/%s/search" % framework, 1), - ("GraphQL/resolve/%s/search.__typename" % framework, 2), - ("GraphQL/resolve/%s/search.author" % framework, 2), - ("GraphQL/resolve/%s/search.author.first_name" % framework, 2), - ("GraphQL/operation/%s/query//search.author.first_name" % framework, 1), - ] - _expected_resolver_attributes = { - "graphql.field.name": "first_name", - "graphql.field.parentType": "Author", - "graphql.field.path": "search.author.first_name", - "graphql.field.returnType": "String%s" % type_annotation, - } + txn_name = metric_stubs[-1][0] + _test_scoped_metrics = [("GraphQL/resolve/%s/%s" % (framework, m[0]), m[1]) for m in metric_stubs[:-1]] + _test_scoped_metrics.append(("GraphQL/operation/%s/%s" % (framework, metric_stubs[-1][0]), metric_stubs[-1][1])) - @validate_span_events(count=2, exact_agents=_expected_resolver_attributes) @validate_transaction_metrics( - "query//search.author.first_name", + txn_name, "GraphQL", scoped_metrics=_test_scoped_metrics, rollup_metrics=_test_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), @@ -204,9 +222,8 @@ def test_resolver_trace_path_fragments(target_application): ) @conditional_decorator(background_task(), is_bg) def _test(): - response = target_application('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }') - expected = {'search': [{'__typename': 'Book', 'author': {'first_name': 'New'}}, {'__typename': 'Book', 'author': {'first_name': 'Bob'}}]} - assert response == expected, response + response = target_application(query) + assert response _test() From 429dea2cbc909410ebfd05058fefb95272997541 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 4 Jan 2023 13:33:19 -0800 Subject: [PATCH 22/26] Move conditional decorator to testing_support --- tests/framework_graphql/test_application.py | 9 +-------- tests/testing_support/util.py | 9 +++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index 195cdc7fe1..aca538966b 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -18,6 +18,7 @@ validate_transaction_errors, validate_transaction_metrics, ) +from testing_support.util import conditional_decorator from testing_support.validators.validate_code_level_metrics import ( validate_code_level_metrics, ) @@ -31,14 +32,6 @@ from newrelic.packages import six -def conditional_decorator(decorator, condition): - def _conditional_decorator(func): - if not condition: - return func - return decorator(func) - - return _conditional_decorator - @pytest.fixture(scope="session") def is_graphql_2(): diff --git a/tests/testing_support/util.py b/tests/testing_support/util.py index 8ec13e427c..8cfdd045cd 100644 --- a/tests/testing_support/util.py +++ b/tests/testing_support/util.py @@ -41,3 +41,12 @@ def get_open_port(): port = s.getsockname()[1] s.close() return port + + +def conditional_decorator(decorator, condition): + def _conditional_decorator(func): + if not condition: + return func + return decorator(func) + + return _conditional_decorator From 174567fb2cf60f9748cdd68634edf2beb66a72b8 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 4 Jan 2023 13:33:56 -0800 Subject: [PATCH 23/26] Rework parametrized resolver test --- tests/framework_graphql/test_application.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index aca538966b..bcf6285ec3 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -174,37 +174,33 @@ def _test(): ("library.book", 1), ("library.book.author", 2), ("library.book.author.first_name", 2), - ("query//library.book.author.first_name", 1), ]), ("{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", [ ("library", 1), ("library.book", 1), ("library.book.author", 2), ("library.book.author.first_name", 2), - ("query//library.book.author.first_name", 1), ]), ('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }', [ ("search", 1), ("search.__typename", 2), ("search.author", 2), ("search.author.first_name", 2), - ("query//search.author.first_name", 1), ]), - ('{ search(contains: "A") { __typename ... on Book { author { first_name } } ... on Magazine { author { first_name } } } }', [ + ('{ search(contains: "A") { __typename ... on Book { author { first_name } } ... on Magazine { name } } }', [ ("search", 1), ("search.__typename", 2), ("search.author", 2), ("search.author.first_name", 2), - ("query//search.author.first_name", 1), ]), ], ids=["standard", "named_fragment", "inline_fragment", "multi_inline_fragment"]) def test_resolver_trace_paths(target_application, query, metric_stubs): framework, version, target_application, is_bg, schema_type, extra_spans = target_application type_annotation = "!" if framework == "Strawberry" else "" - txn_name = metric_stubs[-1][0] - _test_scoped_metrics = [("GraphQL/resolve/%s/%s" % (framework, m[0]), m[1]) for m in metric_stubs[:-1]] - _test_scoped_metrics.append(("GraphQL/operation/%s/%s" % (framework, metric_stubs[-1][0]), metric_stubs[-1][1])) + txn_name = "query//%s" % metric_stubs[-1][0] + _test_scoped_metrics = [("GraphQL/resolve/%s/%s" % (framework, m[0]), m[1]) for m in metric_stubs] + _test_scoped_metrics.append(("GraphQL/operation/%s/query//%s" % (framework, metric_stubs[-1][0]), 1)) @validate_transaction_metrics( txn_name, From 15cabcbb563be7eb9387c0a9e727067f122af9c0 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 4 Jan 2023 14:24:52 -0800 Subject: [PATCH 24/26] Add pytest parametrization from dict --- tests/testing_support/util.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/testing_support/util.py b/tests/testing_support/util.py index 8cfdd045cd..fd5e6c0f89 100644 --- a/tests/testing_support/util.py +++ b/tests/testing_support/util.py @@ -14,6 +14,8 @@ import re import socket +import pytest +from newrelic.packages import six def _to_int(version_str): m = re.match(r'\d+', version_str) @@ -50,3 +52,8 @@ def _conditional_decorator(func): return decorator(func) return _conditional_decorator + + +def pytest_parametrize_from_dict(argnames, argvalues, *args, **kwargs): + pytest_params = [pytest.param(*values, id=key) for key, values in six.iteritems(argvalues)] + return pytest.mark.parametrize(argnames, pytest_params, *args, **kwargs) From 9027f9a82a610990474443cdf2a4d58b9d279369 Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 4 Jan 2023 14:25:15 -0800 Subject: [PATCH 25/26] Fully expand resolver path tests --- tests/framework_graphql/test_application.py | 68 +++++++++++++++++---- 1 file changed, 55 insertions(+), 13 deletions(-) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index bcf6285ec3..e73e40af70 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -12,13 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections import OrderedDict + import pytest from testing_support.fixtures import ( dt_enabled, validate_transaction_errors, validate_transaction_metrics, ) -from testing_support.util import conditional_decorator +from testing_support.util import conditional_decorator, pytest_parametrize_from_dict from testing_support.validators.validate_code_level_metrics import ( validate_code_level_metrics, ) @@ -167,36 +169,76 @@ def _test(): _test() - -@pytest.mark.parametrize("query,metric_stubs", [ - ("{ library(index: 0) { book { author { first_name }} } }", [ +_test_resolver_trace_paths_queries = OrderedDict([ + ("basic", ("{ hello }", [ + ("hello", 1), + ])), + ("field_error", ("{ error }", [ + ("error", 1), + ])), + ("arguments", ('{ echo(echo: "test") }', [ + ("echo", 1), + ])), + ("aliases", ('{ TestEcho: echo(echo: "test") }', [ + ("echo", 1), + ])), + ("complex", ("{ library(index: 0) { branch, book { branch, author { first_name }} } }", [ + ("library", 1), + ("library.branch", 1), + ("library.book", 1), + ("library.book.branch", 2), + ("library.book.author", 2), + ("library.book.author.first_name", 2), + ])), + ("named_fragment", ("{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", [ ("library", 1), ("library.book", 1), ("library.book.author", 2), ("library.book.author.first_name", 2), - ]), - ("{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", [ + ])), + ("multiple_named_fragments", ("{ library(index: 0) { book { ...BookFragment } magazine { ...MagazineFragment } } } fragment BookFragment on Book { author { first_name } } fragment MagazineFragment on Magazine { name }", [ ("library", 1), ("library.book", 1), ("library.book.author", 2), ("library.book.author.first_name", 2), - ]), - ('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }', [ + ("library.magazine", 1), + ("library.magazine.name", 2), + ])), + ("inline_fragment_filtering", ('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }', [ ("search", 1), ("search.__typename", 2), ("search.author", 2), ("search.author.first_name", 2), - ]), - ('{ search(contains: "A") { __typename ... on Book { author { first_name } } ... on Magazine { name } } }', [ + ])), + ("multiple_inline_fragment_filtering", ('{ search(contains: "A") { __typename ... on Book { author { first_name } } ... on Magazine { name } } }', [ ("search", 1), ("search.__typename", 2), ("search.author", 2), ("search.author.first_name", 2), - ]), -], ids=["standard", "named_fragment", "inline_fragment", "multi_inline_fragment"]) + ("search.name", 2), + ])), + ("named_fragment_filtering", ('{ search(contains: "A") { __typename BookFragment } } fragment BookFragment on Book { author { first_name } }', [ + ("search", 1), + ("search.__typename", 2), + ("search.author", 2), + ("search.author.first_name", 2), + ])), + ("multiple_named_fragment_filtering", ('{ search(contains: "A") { __typename BookFragment MagazineFragment } } fragment BookFragment on Book { author { first_name } } fragment MagazineFragment on Magazine { name }', [ + ("search", 1), + ("search.__typename", 2), + ("search.author", 2), + ("search.author.first_name", 2), + ("search.name", 2), + ])), + ("multiple_root_selections", ('{ hello echo(echo: "test") }', [ + ("hello", 1), + ("echo", 1), + ])), +]) + +@pytest_parametrize_from_dict("query,metric_stubs", _test_resolver_trace_paths_queries) def test_resolver_trace_paths(target_application, query, metric_stubs): framework, version, target_application, is_bg, schema_type, extra_spans = target_application - type_annotation = "!" if framework == "Strawberry" else "" txn_name = "query//%s" % metric_stubs[-1][0] _test_scoped_metrics = [("GraphQL/resolve/%s/%s" % (framework, m[0]), m[1]) for m in metric_stubs] From 4c000389acb37a23972f55136de7a65662a8372a Mon Sep 17 00:00:00 2001 From: Tim Pansino Date: Wed, 4 Jan 2023 17:35:22 -0800 Subject: [PATCH 26/26] WIP Combined implementation for graphql 2/3 --- newrelic/hooks/framework_graphql.py | 155 ++++++++++++------ .../framework_graphql/_target_schema_async.py | 2 +- .../_target_schema_promise.py | 2 +- .../framework_graphql/_target_schema_sync.py | 2 +- tests/framework_graphql/test_application.py | 59 +++---- 5 files changed, 138 insertions(+), 82 deletions(-) diff --git a/newrelic/hooks/framework_graphql.py b/newrelic/hooks/framework_graphql.py index 5999c624db..5d84afc832 100644 --- a/newrelic/hooks/framework_graphql.py +++ b/newrelic/hooks/framework_graphql.py @@ -66,7 +66,8 @@ def as_promise(f): GRAPHQL_IGNORED_FIELDS = frozenset(("id", "__typename")) -GRAPHQL_INTROSPECTION_FIELDS = frozenset(("__schema", "__type")) +GRAPHQL_IGNORED_TRANSACTION_FIELDS = frozenset(("__schema", "__type")) +GRAPHQL_INTROSPECTION_FIELDS = frozenset(("__schema", "__type", "__typekind", "__field", "__inputvalue", "__enumvalue", "__directive", "__typename")) VERSION = None @@ -188,7 +189,7 @@ def wrap_execute_operation(wrapped, instance, args, kwargs): fields = operation.selection_set.selections # Ignore transactions for introspection queries for field in fields: - if get_node_value(field, "name") in GRAPHQL_INTROSPECTION_FIELDS: + if get_node_value(field, "name") in GRAPHQL_IGNORED_TRANSACTION_FIELDS: ignore_transaction() fragments = execution_context.fragments @@ -232,12 +233,13 @@ def is_fragment_spread_node(field): def is_fragment(field): # Resolve version specific imports try: - from graphql.language.ast import FragmentSpread, InlineFragment + from graphql.language.ast import FragmentSpread, InlineFragment, FragmentDefinition except ImportError: from graphql import FragmentSpreadNode as FragmentSpread from graphql import InlineFragmentNode as InlineFragment + from graphql import FragmentDefinitionNode as FragmentDefinition - _fragment_types = (InlineFragment, FragmentSpread) + _fragment_types = (InlineFragment, FragmentSpread, FragmentDefinition) return isinstance(field, _fragment_types) @@ -256,6 +258,40 @@ def is_named_fragment(field): ) +def lookup_fields_for_fragments(fields, fragments): + fragment_spreads = [] + true_fields = {} + + fields = deque(fields) + while fields: + field = fields.popleft() + if is_fragment_spread_node(field): + fragment_def = fragments[get_node_value(field, "name")] + selections = getattr(getattr(fragment_def, "selection_set", None), "selections", []) + fragment_spreads.append(selections) + elif is_named_fragment(field): + selections = getattr(getattr(field, "selection_set", None), "selections", []) + subfields = lookup_fields_for_fragments(selections, fragments) + for subfield in subfields: + # Add inline fragment as all subfields within it to properly parse out subtype name later + true_fields[subfield] = field + + if not is_fragment(field): + true_fields[get_node_value(field, "name")] = field + else: + # Add fragment subfields and properly parse out subtype name later + if is_fragment_spread_node(field): + fragment_def = fragments[get_node_value(field, "name")] + elif is_named_fragment(field): + fragment_def = field + selections = getattr(getattr(fragment_def, "selection_set", None), "selections", []) + subfields = lookup_fields_for_fragments(selections, fragments) + for subfield in subfields: + true_fields[subfield] = fragment_def + + return true_fields + + def filter_ignored_fields(fields): filtered_fields = [f for f in fields if get_node_value(f, "name") not in GRAPHQL_IGNORED_FIELDS] return filtered_fields @@ -272,22 +308,29 @@ def traverse_deepest_unique_path(fields, fragments): fragment_selection_set = [] if is_named_fragment(field): - name = get_node_value(field.type_condition, "name") - if name: - deepest_path.append("%s<%s>" % (deepest_path.pop(), name)) + fragment_type_name = get_node_value(field.type_condition, "name") + if fragment_type_name: + deepest_path[-1] += "<%s>" % fragment_type_name - elif is_fragment(field): + elif is_fragment_spread_node(field): if len(list(fragments.values())) != 1: return deepest_path # list(fragments.values())[0] 's index is OK because the previous line # ensures that there is only one field in the list - full_fragment_selection_set = list(fragments.values())[0].selection_set.selections + fragment_def = list(fragments.values())[0] + full_fragment_selection_set = fragment_def.selection_set.selections fragment_selection_set = filter_ignored_fields(full_fragment_selection_set) if len(fragment_selection_set) != 1: return deepest_path else: + # Add subtype in angle brackets + fragment_type_name = get_node_value(fragment_def.type_condition, "name") + if fragment_type_name: + deepest_path[-1] += "<%s>" % fragment_type_name + + # Add subfield fragment_field_name = get_node_value(fragment_selection_set[0], "name") deepest_path.append(fragment_field_name) @@ -483,50 +526,60 @@ def wrap_resolve_field(wrapped, instance, args, kwargs): if isinstance(field_path, list): execution_context = execution_context or instance operation = getattr(parent_info, "operation", None) + else: + execution_context = instance + operation = execution_context.operation - if getattr(operation, "selection_set", None) is None: - path = field_path - else: - fields = operation.selection_set.selections - fragments = execution_context.fragments - - path = [] - - field_path = deque(field_path) - while field_path: - item = field_path.popleft() - if isinstance(item, int): - # Ignore list indexes - continue - - field_dict = {f.name.value: f for f in fields if not is_fragment(f)} - field_def = field_dict.get(item, None) - if field_def is not None: - path.append(item) - fields = getattr(getattr(field_def, "selection_set", None), "selections", []) - elif item.startswith("__"): - # Instrospection field - path.append(item) - break - else: - field_fragments = [f for f in fields if is_named_fragment(f)] - if len(field_fragments) == 1: - current_fragment = field_fragments[0] - path[-1] += "<%s>" % current_fragment.type_condition.name.value - fields = current_fragment.selection_set.selections - field_path.appendleft(item) # Process item again - else: - raise ValueError() - - field_path = ".".join(path) + if getattr(operation, "selection_set", None) is None: + discovered_path = field_path else: - path = [field_path.key] - field_path = getattr(field_path, "prev", None) - while field_path is not None: - if field_path.typename is not None: - path.insert(0, field_path.key) - field_path = getattr(field_path, "prev", None) - field_path = ".".join(path) + fields = operation.selection_set.selections + fragments = execution_context.fragments + + discovered_path = [] + + # Turn field path into deque for iteration + if hasattr(field_path, "prev"): + _field_path = field_path + field_path_queue = deque() + while _field_path is not None: + # Walk field path + field_path_queue.appendleft(_field_path) + _field_path = _field_path.prev + else: + field_path_queue = deque(field_path) + + while field_path_queue: + item_ref = field_path_queue.popleft() + # Unpack Graphql v3 paths + item_typename = getattr(item_ref, "typename", None) + item = getattr(item_ref, "key", item_ref) + + if isinstance(item, int): + # Ignore list indexes + continue + + field_dict = lookup_fields_for_fragments(fields, fragments) + field_def = field_dict.get(item, None) + + if is_fragment(field_def): + type_condition = getattr(field_def, "type_condition", None) + if type_condition is not None: + fragment_type_name = get_node_value(field_def.type_condition, "name") + discovered_path[-1] += "<%s>" % fragment_type_name + fields = getattr(getattr(field_def, "selection_set", None), "selections", []) + field_path_queue.appendleft(item) + elif field_def is not None: + discovered_path.append(item) + fields = getattr(getattr(field_def, "selection_set", None), "selections", []) + elif item in GRAPHQL_INTROSPECTION_FIELDS: + # Instrospection field + discovered_path.append(item) + fields = [] + else: + raise RuntimeError("Unable to parse path from field definitions.") + + field_path = ".".join(discovered_path) except Exception: field_path = field_name diff --git a/tests/framework_graphql/_target_schema_async.py b/tests/framework_graphql/_target_schema_async.py index 26238348cf..894483f506 100644 --- a/tests/framework_graphql/_target_schema_async.py +++ b/tests/framework_graphql/_target_schema_async.py @@ -93,7 +93,7 @@ async def resolve_search(parent, info, contains): Storage = GraphQLList(GraphQLString) -item_type_resolver = lambda x, _: "Book" if "isbn" in x else "Magazine" +item_type_resolver = lambda x, *_: "Book" if "isbn" in x else "Magazine" Item = GraphQLUnionType("Item", (Book, Magazine), resolve_type=item_type_resolver) diff --git a/tests/framework_graphql/_target_schema_promise.py b/tests/framework_graphql/_target_schema_promise.py index dafcf9b467..e1d2adf848 100644 --- a/tests/framework_graphql/_target_schema_promise.py +++ b/tests/framework_graphql/_target_schema_promise.py @@ -95,7 +95,7 @@ def resolve_search(parent, info, contains): Storage = GraphQLList(GraphQLString) -item_type_resolver = lambda x, _: "Book" if "isbn" in x else "Magazine" +item_type_resolver = lambda x, *_: "Book" if "isbn" in x else "Magazine" Item = GraphQLUnionType("Item", (Book, Magazine), resolve_type=item_type_resolver) diff --git a/tests/framework_graphql/_target_schema_sync.py b/tests/framework_graphql/_target_schema_sync.py index 3fd0a2a149..70178ed49c 100644 --- a/tests/framework_graphql/_target_schema_sync.py +++ b/tests/framework_graphql/_target_schema_sync.py @@ -145,7 +145,7 @@ def resolve_search(parent, info, contains): Storage = GraphQLList(GraphQLString) -item_type_resolver = lambda x, _: "Book" if "isbn" in x else "Magazine" +item_type_resolver = lambda x, *_: "Book" if "isbn" in x else "Magazine" Item = GraphQLUnionType("Item", (Book, Magazine), resolve_type=item_type_resolver) diff --git a/tests/framework_graphql/test_application.py b/tests/framework_graphql/test_application.py index e73e40af70..36fd213a4b 100644 --- a/tests/framework_graphql/test_application.py +++ b/tests/framework_graphql/test_application.py @@ -170,19 +170,19 @@ def _test(): _test() _test_resolver_trace_paths_queries = OrderedDict([ - ("basic", ("{ hello }", [ + ("basic", ("{ hello }", "query//hello", [ ("hello", 1), ])), - ("field_error", ("{ error }", [ + ("field_error", ("{ error }", "framework_{framework}._target_schema_{schema}:resolve_error", [ ("error", 1), ])), - ("arguments", ('{ echo(echo: "test") }', [ + ("arguments", ('{ echo(echo: "test") }', "query//echo", [ ("echo", 1), ])), - ("aliases", ('{ TestEcho: echo(echo: "test") }', [ - ("echo", 1), - ])), - ("complex", ("{ library(index: 0) { branch, book { branch, author { first_name }} } }", [ + # ("aliases", ('{ TestEcho: echo(echo: "test") }', "query//echo", [ + # ("echo", 1), + # ])), + ("complex", ("{ library(index: 0) { branch, book { branch, author { first_name }} } }", "query//library", [ ("library", 1), ("library.branch", 1), ("library.book", 1), @@ -190,13 +190,13 @@ def _test(): ("library.book.author", 2), ("library.book.author.first_name", 2), ])), - ("named_fragment", ("{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", [ + ("named_fragment", ("{ library(index: 0) { book { ...MyFragment } } } fragment MyFragment on Book { author { first_name } }", "query//library.book.author.first_name", [ ("library", 1), ("library.book", 1), ("library.book.author", 2), ("library.book.author.first_name", 2), ])), - ("multiple_named_fragments", ("{ library(index: 0) { book { ...BookFragment } magazine { ...MagazineFragment } } } fragment BookFragment on Book { author { first_name } } fragment MagazineFragment on Magazine { name }", [ + ("multiple_named_fragments", ("{ library(index: 0) { ...BookFragment ...MagazineFragment } } fragment BookFragment on Library { book { author { first_name } } } fragment MagazineFragment on Library { magazine { name } }", "query//library", [ ("library", 1), ("library.book", 1), ("library.book.author", 2), @@ -204,48 +204,47 @@ def _test(): ("library.magazine", 1), ("library.magazine.name", 2), ])), - ("inline_fragment_filtering", ('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }', [ + ("inline_fragment_filtering", ('{ search(contains: "A") { __typename ... on Book { author { first_name } } } }', "query//search.author.first_name", [ ("search", 1), ("search.__typename", 2), ("search.author", 2), ("search.author.first_name", 2), ])), - ("multiple_inline_fragment_filtering", ('{ search(contains: "A") { __typename ... on Book { author { first_name } } ... on Magazine { name } } }', [ + ("multiple_inline_fragment_filtering", ('{ search(contains: "e") { __typename ... on Book { author { first_name } } ... on Magazine { name } } }', "query//search", [ ("search", 1), - ("search.__typename", 2), - ("search.author", 2), - ("search.author.first_name", 2), - ("search.name", 2), + ("search.__typename", 6), + ("search.author", 3), + ("search.author.first_name", 3), + ("search.name", 3), ])), - ("named_fragment_filtering", ('{ search(contains: "A") { __typename BookFragment } } fragment BookFragment on Book { author { first_name } }', [ + ("named_fragment_filtering", ('{ search(contains: "A") { __typename ...BookFragment } } fragment BookFragment on Book { author { first_name } }', "query//search.author.first_name", [ ("search", 1), ("search.__typename", 2), ("search.author", 2), ("search.author.first_name", 2), ])), - ("multiple_named_fragment_filtering", ('{ search(contains: "A") { __typename BookFragment MagazineFragment } } fragment BookFragment on Book { author { first_name } } fragment MagazineFragment on Magazine { name }', [ + ("multiple_named_fragment_filtering", ('{ search(contains: "e") { __typename ...BookFragment ...MagazineFragment } } fragment BookFragment on Book { author { first_name } } fragment MagazineFragment on Magazine { name }', "query//search", [ ("search", 1), - ("search.__typename", 2), - ("search.author", 2), - ("search.author.first_name", 2), - ("search.name", 2), + ("search.__typename", 6), + ("search.author", 3), + ("search.author.first_name", 3), + ("search.name", 3), ])), - ("multiple_root_selections", ('{ hello echo(echo: "test") }', [ + ("multiple_root_selections", ('{ hello echo(echo: "test") }', "query//", [ ("hello", 1), ("echo", 1), ])), ]) -@pytest_parametrize_from_dict("query,metric_stubs", _test_resolver_trace_paths_queries) -def test_resolver_trace_paths(target_application, query, metric_stubs): +@pytest_parametrize_from_dict("query,transaction_name,metric_stubs", _test_resolver_trace_paths_queries) +def test_resolver_trace_paths(target_application, query, transaction_name, metric_stubs): framework, version, target_application, is_bg, schema_type, extra_spans = target_application - txn_name = "query//%s" % metric_stubs[-1][0] + transaction_name = transaction_name.format(framework=framework.lower(), schema=schema_type) _test_scoped_metrics = [("GraphQL/resolve/%s/%s" % (framework, m[0]), m[1]) for m in metric_stubs] - _test_scoped_metrics.append(("GraphQL/operation/%s/query//%s" % (framework, metric_stubs[-1][0]), 1)) @validate_transaction_metrics( - txn_name, + transaction_name, "GraphQL", scoped_metrics=_test_scoped_metrics, rollup_metrics=_test_scoped_metrics + _graphql_base_rollup_metrics(framework, version, is_bg), @@ -663,11 +662,15 @@ def _test(): "{ library(index: 0) { book { ...MyFragment } magazine { ...MagFragment } } } fragment MyFragment on Book { author { first_name } } fragment MagFragment on Magazine { name }", "/library", ), + ( + '{ search(contains: "A") { __typename ...BookFragment } } fragment BookFragment on Book { author { first_name } }', + "/search.author.first_name", + ), # Named fragment filtering ] @dt_enabled -@pytest.mark.parametrize("query,expected_path", _test_queries) +@pytest.mark.parametrize("query,expected_path", _test_queries, ids=(i for i, _ in enumerate(_test_queries))) def test_deepest_unique_path(target_application, query, expected_path): framework, version, target_application, is_bg, schema_type, extra_spans = target_application if expected_path == "/error":