From f40653d3baa92674f3d13d5a3ec5e8b37a71a085 Mon Sep 17 00:00:00 2001 From: David Acevedo Date: Sat, 18 Nov 2023 14:00:23 -0500 Subject: [PATCH] feat: Add graphql-core instrumentation --- .github/workflows/dev.yaml | 8 + .../instrumentation/graphql_core/README.md | 65 ++++ .../instrumentation/graphql_core/__init__.py | 321 ++++++++++++++++++ .../instrumentation/graphql_core/package.py | 1 + .../instrumentation/graphql_core/py.typed | 0 .../graphql_core/runtime/deps.yaml | 6 + .../graphql_core/runtime/main.nix | 18 + .../graphql_core/runtime/sources.yaml | 153 +++++++++ .../graphql_core/tests/__init__.py | 0 .../tests/test_instrumentation.py | 165 +++++++++ .../instrumentation/graphql_core/version.py | 1 + otelcontribs/makes.nix | 10 + 12 files changed, 748 insertions(+) create mode 100644 otelcontribs/instrumentation/graphql_core/README.md create mode 100644 otelcontribs/instrumentation/graphql_core/__init__.py create mode 100644 otelcontribs/instrumentation/graphql_core/package.py create mode 100644 otelcontribs/instrumentation/graphql_core/py.typed create mode 100644 otelcontribs/instrumentation/graphql_core/runtime/deps.yaml create mode 100644 otelcontribs/instrumentation/graphql_core/runtime/main.nix create mode 100644 otelcontribs/instrumentation/graphql_core/runtime/sources.yaml create mode 100644 otelcontribs/instrumentation/graphql_core/tests/__init__.py create mode 100644 otelcontribs/instrumentation/graphql_core/tests/test_instrumentation.py create mode 100644 otelcontribs/instrumentation/graphql_core/version.py diff --git a/.github/workflows/dev.yaml b/.github/workflows/dev.yaml index d0e0888..9faeeca 100644 --- a/.github/workflows/dev.yaml +++ b/.github/workflows/dev.yaml @@ -26,6 +26,14 @@ jobs: with: set-safe-directory: /github/workspace args: sh -c "chown -R root:root /github/workspace && m . /lintPython/module/aiobotocore" + lintPython_module_graphql_core: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker://ghcr.io/fluidattacks/makes/amd64:latest + with: + set-safe-directory: /github/workspace + args: sh -c "chown -R root:root /github/workspace && m . /lintPython/module/graphql_core" name: dev on: push: diff --git a/otelcontribs/instrumentation/graphql_core/README.md b/otelcontribs/instrumentation/graphql_core/README.md new file mode 100644 index 0000000..5846ae5 --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/README.md @@ -0,0 +1,65 @@ +# OpenTelemetry Instrumentation for graphql-core + +[![PyPI version](https://badge.fury.io/py/otelcontribs-instrumentation-graphql-core.svg)](https://badge.fury.io/py/otelcontribs-instrumentation-graphql-core) + +This library allows tracing the parsing, validation and execution of queries performed by [graphql-core](https://pypi.org/project/graphql-core). + +## Installation + +```bash +pip install otelcontribs-instrumentation-graphql-core +``` + +## Usage + +Programmatically enable instrumentation via the following code: + +```python + # Instrument GraphQL-core + from otelcontribs.instrumentation.graphql_core import GraphQLCoreInstrumentor + + GraphQLCoreInstrumentor().instrument() + + # This will create a span with GraphQL-specific attributes + from graphql import ( + GraphQLField, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, + graphql, + ) + + def resolve_hello(parent, info): + return "Hello world!" + + schema = GraphQLSchema( + query=GraphQLObjectType( + name="RootQueryType", + fields={ + "hello": GraphQLField(GraphQLString, resolve=resolve_hello) + }, + ) + ) + + await graphql(schema, "{ hello }") +``` + +## API + +The `instrument` method accepts the following keyword args: + +- tracer_provider (TracerProvider) - an optional tracer provider +- skip_default_resolvers (Boolean) - whether to skip spans for default resolvers. True by default +- skip_introspection_query (Boolean) - whether to skip introspection queries. True by default + +for example: + +```python + # Instrument GraphQL-core + from otelcontribs.instrumentation.graphql_core import GraphQLCoreInstrumentor + + GraphQLCoreInstrumentor().instrument( + skip_default_resolvers=False, + skip_introspection_query=False, + ) +``` diff --git a/otelcontribs/instrumentation/graphql_core/__init__.py b/otelcontribs/instrumentation/graphql_core/__init__.py new file mode 100644 index 0000000..ffaec60 --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/__init__.py @@ -0,0 +1,321 @@ +import graphql +from graphql import ( + default_field_resolver, + DocumentNode, + ExecutionContext, + FieldNode, + get_operation_ast, + GraphQLError, + GraphQLField, + GraphQLFieldResolver, + GraphQLObjectType, + OperationDefinitionNode, + Source, +) +from graphql.execution.execute import ( + get_field_def, +) +from graphql.language.parser import ( + SourceType, +) +import importlib +import re +from typing import ( + Any, + Callable, + cast, + Collection, + Dict, + List, + Optional, + Tuple, + Union, +) + +try: + # Faster, but only available from 3.1.0 onwards + from graphql.pyutils import ( + is_awaitable, + ) +except ImportError: + from inspect import isawaitable as is_awaitable # type: ignore + +from opentelemetry import ( + context, +) +from opentelemetry.context import ( + _SUPPRESS_INSTRUMENTATION_KEY, +) +from opentelemetry.instrumentation.instrumentor import ( # type: ignore + BaseInstrumentor, +) +from opentelemetry.instrumentation.utils import ( + unwrap, +) +from opentelemetry.trace import ( + get_tracer, + Span, +) +from otelcontribs.instrumentation.graphql_core.package import ( + INSTRUMENTS, +) +from otelcontribs.instrumentation.graphql_core.version import ( + VERSION, +) +from wrapt import ( + wrap_function_wrapper, +) + +graphql_module = importlib.import_module("graphql.graphql") +graphql_execute_module = importlib.import_module("graphql.execution.execute") + + +class GraphQLCoreInstrumentor(BaseInstrumentor): + """An instrumentor for GraphQL-core.""" + + def __init__(self) -> None: + super().__init__() + self._tracer = get_tracer(__name__, VERSION) + self.skip_default_resolvers = False + self.skip_introspection_query = False + + def instrumentation_dependencies(self) -> Collection[str]: + return INSTRUMENTS + + def _instrument(self, **kwargs: Any) -> None: + self._tracer = get_tracer( + __name__, VERSION, kwargs.get("tracer_provider") + ) + self.skip_default_resolvers = kwargs.get( + "skip_default_resolvers", True + ) + self.skip_introspection_query = kwargs.get( + "skip_introspection_query", True + ) + + wrap_function_wrapper( + graphql, + "parse", + self._patched_parse, + ) + wrap_function_wrapper( + graphql_module, + "parse", + self._patched_parse, + ) + wrap_function_wrapper( + graphql.validation, + "validate", + self._patched_validate, + ) + wrap_function_wrapper( + graphql, + "execute", + self._patched_execute, + ) + wrap_function_wrapper( + graphql_module, + "execute", + self._patched_execute, + ) + wrap_function_wrapper( + graphql, + "ExecutionContext.execute_field", + self._patched_execute_field, + ) + + def _uninstrument(self, **_kwargs: Any) -> None: + unwrap(graphql, "parse") + unwrap(graphql_module, "parse") + unwrap(graphql.validation, "validate") + unwrap(graphql, "execute") + unwrap(graphql_module, "execute") + unwrap(ExecutionContext, "execute_field") + + def _patched_parse( + self, + original_func: Callable[..., Any], + _instance: Any, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> Any: + if context.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return original_func(*args, **kwargs) + + with self._tracer.start_as_current_span("graphql.parse") as span: + source_arg: SourceType = args[0] + _set_document_attr(span, source_arg) + + return original_func(*args, **kwargs) + + def _patched_validate( + self, + original_func: Callable[..., Any], + _instance: Any, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> Any: + if context.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return original_func(*args, **kwargs) + + with self._tracer.start_as_current_span("graphql.validate") as span: + document_arg: DocumentNode = args[1] + _set_document_attr(span, document_arg) + + errors = original_func(*args, **kwargs) + _set_errors(span, errors) + return errors + + def _patched_execute( + self, + original_func: Callable[..., Any], + _instance: Any, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> Any: + if context.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return original_func(*args, **kwargs) + + with self._tracer.start_as_current_span("graphql.execute") as span: + document_arg: DocumentNode = args[1] + _set_operation_attrs(span, document_arg) + result = original_func(*args, **kwargs) + + if is_awaitable(result): + + async def await_result() -> Any: + with self._tracer.start_as_current_span( + "graphql.execute.await" + ) as span: + _set_operation_attrs(span, document_arg) + async_result = await result + _set_errors(span, async_result.errors) + return async_result + + return await_result() + _set_errors(span, result.errors) + return result + + def _patched_execute_field( + self, + original_func: Callable[..., Any], + instance: ExecutionContext, + args: Tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> Any: + if context.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return original_func(*args, **kwargs) + + parent_type_arg: GraphQLObjectType = args[0] + field_nodes_arg: List[FieldNode] = args[2] + field_node = field_nodes_arg[0] + field = get_field_def(instance.schema, parent_type_arg, field_node) + + if _should_skip_field( + field, + instance.operation, + self.skip_default_resolvers, + self.skip_introspection_query, + ): + return original_func(*args, **kwargs) + + with self._tracer.start_as_current_span("graphql.resolve") as span: + _set_field_attrs(span, field_node) + result = original_func(*args, **kwargs) + + if is_awaitable(result): + + async def await_result() -> Any: + with self._tracer.start_as_current_span( + "graphql.resolve.await" + ) as span: + _set_field_attrs(span, field_node) + return await result + + return await_result() + return result + + +def _format_source(obj: Union[DocumentNode, Source, str]) -> str: + if isinstance(obj, str): + value = obj + elif isinstance(obj, Source): + value = obj.body + elif isinstance(obj, DocumentNode) and obj.loc: + value = obj.loc.source.body + else: + value = "" + + return re.sub(r"\s+", " ", value).strip() + + +def _set_document_attr( + span: Span, obj: Union[DocumentNode, Source, str] +) -> None: + source = _format_source(obj) + span.set_attribute("graphql.document", source) + + +def _set_operation_attrs(span: Span, document: DocumentNode) -> None: + _set_document_attr(span, document) + + operation_definition = get_operation_ast(document) + + if operation_definition: + span.set_attribute( + "graphql.operation.type", + operation_definition.operation.value, + ) + + if operation_definition.name: + span.set_attribute( + "graphql.operation.name", + operation_definition.name.value, + ) + + +def _set_errors(span: Span, errors: Optional[List[GraphQLError]]) -> None: + if errors: + for error in errors: + span.record_exception(error) + + +def _set_field_attrs(span: Span, field_node: FieldNode) -> None: + span.set_attribute("graphql.field.name", field_node.name.value) + + +def _is_default_resolver(resolver: Optional[GraphQLFieldResolver]) -> bool: + # pylint: disable=comparison-with-callable + return ( + # graphql-core + resolver is None + or resolver == default_field_resolver + # ariadne + or getattr(resolver, "_ariadne_alias_resolver", False) + # strawberry + or getattr(resolver, "_is_default", False) + ) + + +def _is_introspection_query(operation: OperationDefinitionNode) -> bool: + selections = operation.selection_set.selections + + if selections: + root_field = cast(FieldNode, selections[0]) + return root_field.name.value == "__schema" + return False + + +def _should_skip_field( + field: GraphQLField, + operation: OperationDefinitionNode, + skip_default_resolvers: bool, + skip_introspection_query: bool, +) -> bool: + if _is_default_resolver(field.resolve): + return skip_default_resolvers + + if _is_introspection_query(operation): + return skip_introspection_query + + return False diff --git a/otelcontribs/instrumentation/graphql_core/package.py b/otelcontribs/instrumentation/graphql_core/package.py new file mode 100644 index 0000000..10ec48e --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/package.py @@ -0,0 +1 @@ +INSTRUMENTS = ["graphql-core ~= 3.0"] diff --git a/otelcontribs/instrumentation/graphql_core/py.typed b/otelcontribs/instrumentation/graphql_core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/otelcontribs/instrumentation/graphql_core/runtime/deps.yaml b/otelcontribs/instrumentation/graphql_core/runtime/deps.yaml new file mode 100644 index 0000000..946ad56 --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/runtime/deps.yaml @@ -0,0 +1,6 @@ +graphql-core: ^3.0.0 +opentelemetry-api: 1.21.0 +opentelemetry-instrumentation: 0.42b0 +opentelemetry-semantic-conventions: 0.42b0 +opentelemetry-test-utils: 0.42b0 +pytest: 7.4.3 diff --git a/otelcontribs/instrumentation/graphql_core/runtime/main.nix b/otelcontribs/instrumentation/graphql_core/runtime/main.nix new file mode 100644 index 0000000..a80a47e --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/runtime/main.nix @@ -0,0 +1,18 @@ +{ + makePythonPypiEnvironment, + makeTemplate, + projectPath, + ... +}: let + pythonRequirements = makePythonPypiEnvironment { + name = "graphql-core-runtime"; + sourcesYaml = ./sources.yaml; + }; +in + makeTemplate { + name = "graphql-core-runtime"; + searchPaths = { + source = [pythonRequirements]; + pythonPackage = [(projectPath "/")]; + }; + } diff --git a/otelcontribs/instrumentation/graphql_core/runtime/sources.yaml b/otelcontribs/instrumentation/graphql_core/runtime/sources.yaml new file mode 100644 index 0000000..5220ef1 --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/runtime/sources.yaml @@ -0,0 +1,153 @@ +closure: + asgiref: 3.7.2 + colorama: 0.4.6 + deprecated: 1.2.14 + graphql-core: 3.2.3 + importlib-metadata: 6.8.0 + iniconfig: 2.0.0 + opentelemetry-api: 1.21.0 + opentelemetry-instrumentation: 0.42b0 + opentelemetry-sdk: 1.21.0 + opentelemetry-semantic-conventions: 0.42b0 + opentelemetry-test-utils: 0.42b0 + packaging: "23.2" + pluggy: 1.3.0 + pytest: 7.4.3 + setuptools: 68.2.2 + typing-extensions: 4.8.0 + wrapt: 1.16.0 + zipp: 3.17.0 +links: + - name: asgiref-3.7.2-py3-none-any.whl + sha256: 0vjgdmw4km0wsrf8myp2d1jfq0z7wb03nrpgdshn5dg38wifzcl9 + url: https://files.pythonhosted.org/packages/9b/80/b9051a4a07ad231558fcd8ffc89232711b4e618c15cb7a392a17384bbeef/asgiref-3.7.2-py3-none-any.whl + - name: asgiref-3.7.2.tar.gz + sha256: 1vdgj8mikd2j6ijlhf7b4n2nxkvq72r1c0hj8mdvl6d8jfmf634y + url: https://files.pythonhosted.org/packages/12/19/64e38c1c2cbf0da9635b7082bbdf0e89052e93329279f59759c24a10cc96/asgiref-3.7.2.tar.gz + - name: colorama-0.4.6-py2.py3-none-any.whl + sha256: 1ijz53xpmxds2qf02l9yf0rnp7bznwh3ci4xkw8wmh5cyn8rj7ag + url: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl + - name: colorama-0.4.6.tar.gz + sha256: 0i3fpq0w5mbfdpy3z9p5raw4fg17jxr6jwh5l8qhavpdnxf5ys88 + url: https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz + - name: Deprecated-1.2.14-py2.py3-none-any.whl + sha256: 0v26f22dg6c434dzz0q5fb2d6cp72nwbj5xvpl107aclfw4qpb3g + url: https://files.pythonhosted.org/packages/20/8d/778b7d51b981a96554f29136cd59ca7880bf58094338085bcf2a979a0e6a/Deprecated-1.2.14-py2.py3-none-any.whl + - name: Deprecated-1.2.14.tar.gz + sha256: 1cq17pavjw291hmzyrbhl9ssfln84b1zkiidb31cr3a56swkwcp5 + url: https://files.pythonhosted.org/packages/92/14/1e41f504a246fc224d2ac264c227975427a85caf37c3979979edb9b1b232/Deprecated-1.2.14.tar.gz + - name: graphql_core-3.2.3-py3-none-any.whl + sha256: 1hrx60zgrfda9zp3mn6xd0w3wwcjvj3z52rz2fxchpmxa827hrjp + url: https://files.pythonhosted.org/packages/f8/39/e5143e7ec70939d2076c1165ae9d4a3815597019c4d797b7f959cf778600/graphql_core-3.2.3-py3-none-any.whl + - name: graphql-core-3.2.3.tar.gz + sha256: 0xk6wizh0sakzjlhkcn17fjmdsa5bhz5v227rfqkagkjmk8amlh6 + url: https://files.pythonhosted.org/packages/ee/a6/94df9045ca1bac404c7b394094cd06713f63f49c7a4d54d99b773ae81737/graphql-core-3.2.3.tar.gz + - name: importlib_metadata-6.8.0-py3-none-any.whl + sha256: 1fv8pr56ksxklidj8yacv4y0arwxbnbmn0j5h9lxf1d8hkgpifry + url: https://files.pythonhosted.org/packages/cc/37/db7ba97e676af155f5fcb1a35466f446eadc9104e25b83366e8088c9c926/importlib_metadata-6.8.0-py3-none-any.whl + - name: importlib_metadata-6.8.0.tar.gz + sha256: 0hs7cpafk8qn63amnm64yij4w7c35win4rh9mp0ll34c5n4ygb6v + url: https://files.pythonhosted.org/packages/33/44/ae06b446b8d8263d712a211e959212083a5eda2bf36d57ca7415e03f6f36/importlib_metadata-6.8.0.tar.gz + - name: iniconfig-2.0.0-py3-none-any.whl + sha256: 0x43fyv1hpwpmvvqh73ldcyac9j2hb14mffis8i3nblxlxqmia5n + url: https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl + - name: iniconfig-2.0.0.tar.gz + sha256: 1cxqanj28jqk0alx2xq4ddgvab5822k6vh8p1d0imlvjpwsy349d + url: https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz + - name: opentelemetry_api-1.21.0-py3-none-any.whl + sha256: 1yyz2ydz6a7rqsp1k6mps6z1lal9wh7ji4qfiw4l2zkvc8l6pf2b + url: https://files.pythonhosted.org/packages/51/3a/945e6c21f405ac4ea526f91ee09cc1568c04e0c95d3392903e6984c8f0e0/opentelemetry_api-1.21.0-py3-none-any.whl + - name: opentelemetry_api-1.21.0.tar.gz + sha256: 05k3d2kldpb0lcpiw6x2ijx3x5bbsb92z0i1v5sh001y0kamy66n + url: https://files.pythonhosted.org/packages/4d/aa/1a10f310275fdd05a1062d4a8a641a5f041db2377956a80ff3c4dc325a6c/opentelemetry_api-1.21.0.tar.gz + - name: opentelemetry_instrumentation-0.42b0-py3-none-any.whl + sha256: 0sg3vdm480mvkgy43gxvl7ml2w9y2xinigqn5mfx18hcp7fm9bk5 + url: https://files.pythonhosted.org/packages/84/33/8e6b97dcb807c1ba5fd84910c091ae4b1b52d74ea24b0574e19f58cce99c/opentelemetry_instrumentation-0.42b0-py3-none-any.whl + - name: opentelemetry_instrumentation-0.42b0.tar.gz + sha256: 161iaickbj4z46zlrypfgsc3wj2hqxviscl552iywxhgxlgklrba + url: https://files.pythonhosted.org/packages/9b/84/35da14ed36660c21f0ec8979e992d1fa8e025189a92ba4b68ce22bea6118/opentelemetry_instrumentation-0.42b0.tar.gz + - name: opentelemetry_sdk-1.21.0-py3-none-any.whl + sha256: 0wvg30qk3nymkf1qwbgaj014qrgwrnfbi873mknmyrcc78j37rlz + url: https://files.pythonhosted.org/packages/c3/08/ca8b1ef7a2fa3f1ea2f12770eca8976098066adb442b1da81fea3b370123/opentelemetry_sdk-1.21.0-py3-none-any.whl + - name: opentelemetry_sdk-1.21.0.tar.gz + sha256: 0yc8bckqiv6kjnd8sr55jmiwn342mvwwl74rr72np39j40qcvj1y + url: https://files.pythonhosted.org/packages/52/38/8edd2d4113705ae7cd877f2c52a98a5b1f385b4f187ba2e48cf2cb4624a7/opentelemetry_sdk-1.21.0.tar.gz + - name: opentelemetry_semantic_conventions-0.42b0-py3-none-any.whl + sha256: 09pm77pb2b7lcciysassjkqgv8nf1xfnqyb0i1jsyj64zv5ikmsw + url: https://files.pythonhosted.org/packages/c3/0c/4c99cbe85b65fbba5a638cb7d913cb3acead3d83b4c47763be28d418bb95/opentelemetry_semantic_conventions-0.42b0-py3-none-any.whl + - name: opentelemetry_semantic_conventions-0.42b0.tar.gz + sha256: 1v1q1n0fqjx9y5ch31qjrza9hb14q5f7x1bp503haai5lfh6gbj4 + url: https://files.pythonhosted.org/packages/a1/83/5b5bfd2d41227274f22f4552832e56af02171423f597a0358d2174736492/opentelemetry_semantic_conventions-0.42b0.tar.gz + - name: opentelemetry_test_utils-0.42b0-py3-none-any.whl + sha256: 1ggg2xjrfab3kwbbk2qcqy6y71z89003d3dj95wx8ig8gxal3z5v + url: https://files.pythonhosted.org/packages/50/76/d36ae10551e60253336cc12c98a9b31a5f6043569e4c9e14b04e83b13c26/opentelemetry_test_utils-0.42b0-py3-none-any.whl + - name: opentelemetry_test_utils-0.42b0.tar.gz + sha256: 0qajzxl4nm5xi6wigkph3s8ws363srna9n71gkdyz1zqrjk4wvnw + url: https://files.pythonhosted.org/packages/ae/66/d4999012e1978ba56204394be8f0fe8e5e688a7bc8d3ca91f51fb0fb5b39/opentelemetry_test_utils-0.42b0.tar.gz + - name: packaging-23.2-py3-none-any.whl + sha256: 1iw8zh1m56r0xmkxxl2dnc4pbx1frkdbbl1iv7hzg6is0f812jcc + url: https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl + - name: packaging-23.2.tar.gz + sha256: 1ifgjb0d0bnnm78hv3mnl7hi233m7jamb2plma752djh83lv13q4 + url: https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz + - name: pluggy-1.3.0-py3-none-any.whl + sha256: 1xv121p7p35mdw50d1vcwv5r6dvs5flwsplfs5vx72rzfxm6k76q + url: https://files.pythonhosted.org/packages/05/b8/42ed91898d4784546c5f06c60506400548db3f7a4b3fb441cba4e5c17952/pluggy-1.3.0-py3-none-any.whl + - name: pluggy-1.3.0.tar.gz + sha256: 04hyclq0fjlq78dpf2amc5vwmls37q7g6b0pa72ggika2a7swqfg + url: https://files.pythonhosted.org/packages/36/51/04defc761583568cae5fd533abda3d40164cbdcf22dee5b7126ffef68a40/pluggy-1.3.0.tar.gz + - name: pytest-7.4.3-py3-none-any.whl + sha256: 1b0zb6wjfrc0llnh1vx8h05igg740baw3xxdfqdsfnd87q49q00d + url: https://files.pythonhosted.org/packages/f3/8c/f16efd81ca8e293b2cc78f111190a79ee539d0d5d36ccd49975cb3beac60/pytest-7.4.3-py3-none-any.whl + - name: pytest-7.4.3.tar.gz + sha256: 1mcch6h9vgfydplgn49csn74xil1sn587k5bknrf7r1dk0vd32fr + url: https://files.pythonhosted.org/packages/38/d4/174f020da50c5afe9f5963ad0fc5b56a4287e3586e3de5b3c8bce9c547b4/pytest-7.4.3.tar.gz + - name: setuptools-68.2.2-py3-none-any.whl + sha256: 0apxfkg49dpi3qzrzg3vqhybfkjj6vvn06nz683acvc70mba6m5l + url: https://files.pythonhosted.org/packages/bb/26/7945080113158354380a12ce26873dd6c1ebd88d47f5bc24e2c5bb38c16a/setuptools-68.2.2-py3-none-any.whl + - name: setuptools-68.2.2.tar.gz + sha256: 11ra53ds0sw6mhgvibys5lbdffyqzkz8jh47hj3c9wfjfr94ghaa + url: https://files.pythonhosted.org/packages/ef/cc/93f7213b2ab5ed383f98ce8020e632ef256b406b8569606c3f160ed8e1c9/setuptools-68.2.2.tar.gz + - name: typing_extensions-4.8.0-py3-none-any.whl + sha256: 187s0lvblj64kf66gxn0mbph3m245fiqscd5x90vd9pr0s4gr4lg + url: https://files.pythonhosted.org/packages/24/21/7d397a4b7934ff4028987914ac1044d3b7d52712f30e2ac7a2ae5bc86dd0/typing_extensions-4.8.0-py3-none-any.whl + - name: typing_extensions-4.8.0.tar.gz + sha256: 1vxpx1d0aw0hbvqyxlb1z16720y36g5cxnybb1skaxybx4wl73nz + url: https://files.pythonhosted.org/packages/1f/7a/8b94bb016069caa12fc9f587b28080ac33b4fbb8ca369b98bc0a4828543e/typing_extensions-4.8.0.tar.gz + - name: wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl + sha256: 02av6wkma0krzhp2s8sr8g55xbnvn0qw5zgs2czl1r1dzs2v8p8s + url: https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl + - name: wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl + sha256: 0zb85r4g08h94q1rjpmaanffv528g7k6vcdd9dl36mx1w877vskm + url: https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl + - name: wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + sha256: 12b31ifbkmhsr5xfbq6p5fn386av0c6xsbng1x6wsrrj7v5gjlm4 + url: https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl + - name: wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl + sha256: 0q40xl1msb8prildr9hva9l077r6vwlvi3blb2chv2f7vvm5kaj3 + url: https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl + - name: wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + sha256: 18fwvp7jkz4ds046xi93g8a8pbgf578rkmmv5ah7m3laqwillmbj + url: https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl + - name: wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl + sha256: 1wrcnz7g69pfvvmxgz325n2rf9yqvy2fha4xv7h4g8xhnhsyxvyj + url: https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl + - name: wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl + sha256: 0mirxwv5s4xfd4wzi7mk6wnr744dahz8rq6aj0fy02vifgzczkvd + url: https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl + - name: wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl + sha256: 0z9k73zjzpw1dqnh4lb9hd6jds953n0k4lf8abs9d7510086avpb + url: https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl + - name: wrapt-1.16.0-py3-none-any.whl + sha256: 1w9fy4g14ck4rxzpfq8sbgvrffxi9chmjq2zfgxg5jwg188c81k9 + url: https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl + - name: wrapt-1.16.0.tar.gz + sha256: 17c9n430v02dyhg5fwskg9zmld1jkzj41b8ygmyd3rvi56ahydsz + url: https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz + - name: zipp-3.17.0-py3-none-any.whl + sha256: 0ccz81b8bf26gizhrq3jwnwbpxrz92nmjg651772v4klc5r3x4hf + url: https://files.pythonhosted.org/packages/d9/66/48866fc6b158c81cc2bfecc04c480f105c6040e8b077bc54c634b4a67926/zipp-3.17.0-py3-none-any.whl + - name: zipp-3.17.0.tar.gz + sha256: 1w5sra87d544gf6nq2a2d6vikjsrqb48rfvq43nr2zng50f4mrl4 + url: https://files.pythonhosted.org/packages/58/03/dd5ccf4e06dec9537ecba8fcc67bbd4ea48a2791773e469e73f94c3ba9a6/zipp-3.17.0.tar.gz +python: "3.11" diff --git a/otelcontribs/instrumentation/graphql_core/tests/__init__.py b/otelcontribs/instrumentation/graphql_core/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/otelcontribs/instrumentation/graphql_core/tests/test_instrumentation.py b/otelcontribs/instrumentation/graphql_core/tests/test_instrumentation.py new file mode 100644 index 0000000..0967819 --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/tests/test_instrumentation.py @@ -0,0 +1,165 @@ +import asyncio +from graphql import ( + graphql, + graphql_sync, + GraphQLField, + GraphQLObjectType, + GraphQLSchema, + GraphQLString, +) +from graphql.type.definition import ( + GraphQLResolveInfo, +) +from opentelemetry.test.test_base import ( + TestBase, +) +from otelcontribs.instrumentation.graphql_core import ( + GraphQLCoreInstrumentor, +) +from typing import ( + Awaitable, + TypeVar, +) + +T = TypeVar("T") + + +def async_call(coro: Awaitable[T]) -> T: + loop = asyncio.get_event_loop() + return loop.run_until_complete(coro) + + +class TestGraphQLCoreInstrumentor(TestBase): + def setUp(self) -> None: + super().setUp() + GraphQLCoreInstrumentor().instrument() + + def tearDown(self) -> None: + super().tearDown() + GraphQLCoreInstrumentor().uninstrument() + + def test_graphql(self) -> None: + async def resolve_hello( + _parent: None, _info: GraphQLResolveInfo + ) -> str: + await asyncio.sleep(0) + return "Hello world!" + + schema = GraphQLSchema( + query=GraphQLObjectType( + name="RootQueryType", + fields={ + "hello": GraphQLField(GraphQLString, resolve=resolve_hello) + }, + ) + ) + + result = async_call(graphql(schema, "query Test { hello }")) + self.assertEqual(result.errors, None) + assert result.data is not None + self.assertEqual(result.data["hello"], "Hello world!") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 6) + + parse_span = spans[0] + self.assertEqual("graphql.parse", parse_span.name) + self.assertEqual( + "query Test { hello }", parse_span.attributes["graphql.document"] + ) + + validate_span = spans[1] + self.assertEqual("graphql.validate", validate_span.name) + self.assertEqual( + "query Test { hello }", + validate_span.attributes["graphql.document"], + ) + + resolve_span = spans[2] + self.assertEqual("graphql.resolve", resolve_span.name) + self.assertEqual( + "hello", resolve_span.attributes["graphql.field.name"] + ) + + execute_span = spans[3] + self.assertEqual("graphql.execute", execute_span.name) + self.assertEqual( + "query Test { hello }", execute_span.attributes["graphql.document"] + ) + self.assertEqual( + "query", execute_span.attributes["graphql.operation.type"] + ) + self.assertEqual( + "Test", execute_span.attributes["graphql.operation.name"] + ) + + resolve_await_span = spans[4] + self.assertEqual("graphql.resolve.await", resolve_await_span.name) + self.assertEqual( + "hello", resolve_await_span.attributes["graphql.field.name"] + ) + + execute_await_span = spans[5] + self.assertEqual("graphql.execute.await", execute_await_span.name) + self.assertEqual( + "query Test { hello }", + execute_await_span.attributes["graphql.document"], + ) + self.assertEqual( + "query", execute_await_span.attributes["graphql.operation.type"] + ) + self.assertEqual( + "Test", execute_await_span.attributes["graphql.operation.name"] + ) + + def test_graphql_sync(self) -> None: + def resolve_hello(_parent: None, _info: GraphQLResolveInfo) -> str: + return "Hello world!" + + schema = GraphQLSchema( + query=GraphQLObjectType( + name="RootQueryType", + fields={ + "hello": GraphQLField(GraphQLString, resolve=resolve_hello) + }, + ) + ) + + result = graphql_sync(schema, "query Test { hello }") + self.assertEqual(result.errors, None) + assert result.data is not None + self.assertEqual(result.data["hello"], "Hello world!") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 4) + + parse_span = spans[0] + self.assertEqual("graphql.parse", parse_span.name) + self.assertEqual( + "query Test { hello }", parse_span.attributes["graphql.document"] + ) + + validate_span = spans[1] + self.assertEqual("graphql.validate", validate_span.name) + self.assertEqual( + "query Test { hello }", + validate_span.attributes["graphql.document"], + ) + + resolve_span = spans[2] + self.assertEqual("graphql.resolve", resolve_span.name) + self.assertEqual( + "hello", resolve_span.attributes["graphql.field.name"] + ) + + execute_span = spans[3] + self.assertEqual("graphql.execute", execute_span.name) + self.assertEqual( + "query Test { hello }", execute_span.attributes["graphql.document"] + ) + self.assertEqual( + "query", execute_span.attributes["graphql.operation.type"] + ) + self.assertEqual( + "Test", execute_span.attributes["graphql.operation.name"] + ) diff --git a/otelcontribs/instrumentation/graphql_core/version.py b/otelcontribs/instrumentation/graphql_core/version.py new file mode 100644 index 0000000..1ac30ae --- /dev/null +++ b/otelcontribs/instrumentation/graphql_core/version.py @@ -0,0 +1 @@ +VERSION = "0.42" diff --git a/otelcontribs/makes.nix b/otelcontribs/makes.nix index e7053d0..c3453fe 100644 --- a/otelcontribs/makes.nix +++ b/otelcontribs/makes.nix @@ -3,6 +3,9 @@ aiobotocore.source = [ outputs."/otelcontribs/instrumentation/aiobotocore/runtime" ]; + graphql_core.source = [ + outputs."/otelcontribs/instrumentation/graphql_core/runtime" + ]; }; lintPython = { modules = { @@ -13,6 +16,13 @@ python = "3.11"; src = "/otelcontribs/instrumentation/aiobotocore"; }; + graphql_core = { + searchPaths.source = [ + outputs."/otelcontribs/instrumentation/graphql_core/runtime" + ]; + python = "3.11"; + src = "/otelcontribs/instrumentation/graphql_core"; + }; }; }; }