├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── VERSION ├── ddtrace_graphql ├── __init__.py ├── base.py ├── patch.py └── utils.py ├── screenshots ├── query.png └── service.png ├── setup.cfg ├── setup.py ├── tests ├── __init__.py └── test_graphql.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | .pytest_cache/ 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | dist: xenial 4 | 5 | python: 6 | - "3.7" 7 | - "nightly" 8 | install: 9 | - pip install -U .[test] 10 | - pip install codecov 11 | script: 12 | - python -m pytest tests --cov-report term-missing --cov ddtrace_graphql 13 | 14 | after_script: 15 | - codecov 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michal Kuffa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | # Include the license file 2 | include LICENSE 3 | include VERSION 4 | include README.rst 5 | include screenshots/query.png 6 | include screenshots/service.png 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION_PART ?= patch 2 | 3 | .PHONY: clean 4 | clean: 5 | @rm -rf build dist ddtrace_graphql.egg-info 6 | 7 | .PHONY: test 8 | test: 9 | @pip install --editable .[test] 10 | @tox 11 | 12 | .PHONY: versionbump 13 | versionbump: 14 | bumpversion \ 15 | --commit \ 16 | --tag \ 17 | --current-version `cat VERSION` \ 18 | $(VERSION_PART) ./VERSION 19 | 20 | .PHONY: build 21 | build: clean 22 | python setup.py sdist bdist_wheel 23 | 24 | .PHONY: testpublish 25 | testpublish: build 26 | twine upload --repository-url https://test.pypi.org/legacy/ dist/* 27 | 28 | 29 | .PHONY: publish 30 | publish: build 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | =============== 3 | ddtrace-graphql 4 | =============== 5 | 6 | 7 | .. image:: https://travis-ci.org/beezz/ddtrace-graphql.svg?branch=master 8 | :target: https://travis-ci.org/beezz/ddtrace-graphql 9 | 10 | .. image:: https://codecov.io/gh/beezz/ddtrace-graphql/branch/master/graph/badge.svg 11 | :target: https://codecov.io/gh/beezz/ddtrace-graphql 12 | 13 | .. image:: https://pyup.io/repos/github/beezz/ddtrace-graphql/shield.svg 14 | :target: https://pyup.io/repos/github/beezz/ddtrace-graphql/ 15 | 16 | 17 | .. image:: https://badge.fury.io/py/ddtrace-graphql.svg 18 | :target: https://badge.fury.io/py/ddtrace-graphql 19 | 20 | 21 | Python library to trace graphql calls with Datadog. 22 | 23 | * `graphql-core `_ 24 | 25 | * `Datadog APM (Tracing) `_ 26 | 27 | * `Datadog Trace Client `_ 28 | 29 | 30 | Compatibility 31 | ------------- 32 | 33 | ``ddtrace-graphql`` is tested with: 34 | 35 | * Python versions: 3.5, 3.6, nightly 36 | * graphql-core: 2.0, 1.1.0, latest 37 | * ddtrace: 0.11.1, 0.10.1, latest 38 | 39 | *Screenshots for pyramid app serving GraphQL with tracing enabled:* 40 | 41 | .. figure:: screenshots/service.png 42 | :scale: 80% 43 | 44 | GraphQL service detail. 45 | 46 | 47 | .. figure:: screenshots/query.png 48 | :scale: 80% 49 | 50 | GraphQL query detail. 51 | 52 | 53 | 54 | Installation 55 | ============ 56 | 57 | Using pip 58 | --------- 59 | 60 | .. code-block:: bash 61 | 62 | $ pip install ddtrace-graphql 63 | 64 | 65 | From source 66 | ------------ 67 | 68 | .. code-block:: bash 69 | 70 | $ git clone https://github.com/beezz/ddtrace-graphql.git 71 | $ cd ddtrace-graphql && python setup.py install 72 | 73 | 74 | Usage 75 | ===== 76 | 77 | To trace all GraphQL requests patch the library. Put this snippet to your 78 | application main entry point. 79 | 80 | 81 | .. code-block:: python 82 | 83 | __import__('ddtrace_graphql').patch() 84 | 85 | # OR 86 | 87 | from ddtrace_graphql import patch 88 | patch() 89 | 90 | 91 | Check out the `datadog trace client `_ 92 | for all supported libraries and frameworks. 93 | 94 | .. note:: For the patching to work properly, ``patch`` needs to be called 95 | before any other imports of the ``graphql`` function. 96 | 97 | .. code-block:: python 98 | 99 | # app/__init__.py 100 | __import__('ddtrace_graphql').patch() 101 | 102 | # from that point all calls to graphql are traced 103 | from graphql import graphql 104 | result = graphql(schema, query) 105 | 106 | 107 | Trace only certain calls with ``traced_graphql`` function 108 | 109 | .. code-block:: python 110 | 111 | from ddtrace_graphql import traced_graphql 112 | traced_graphql(schema, query) 113 | 114 | 115 | Configuration 116 | ============= 117 | 118 | Environment variables 119 | ===================== 120 | 121 | :DDTRACE_GRAPHQL_SERVICE: Define service name under which traces are shown in Datadog. Default value is ``graphql`` 122 | 123 | 124 | .. code-block:: bash 125 | 126 | $ export DDTRACE_GRAPHQL_SERVICE=foobar.graphql 127 | 128 | 129 | span_kwargs 130 | =========== 131 | 132 | Default arguments passed to the tracing context manager can be updated using 133 | ``span_kwargs`` argument of ``ddtrace_graphql.patch`` or 134 | ``ddtrace_graphql.traced_graphql`` functions. 135 | 136 | Default values: 137 | 138 | :name: Wrapped resource name. Default ``graphql.graphql``. 139 | :span_type: Span type. Default ``graphql``. 140 | :service: Service name. Defaults to ``DDTRACE_GRAPHQL_SERVICE`` environment variable if present, else ``graphql``. 141 | :resource: Processed resource. Defaults to query / mutation signature. 142 | 143 | For more information visit `ddtrace.Tracer.trace `_ documentation. 144 | 145 | 146 | .. code-block:: python 147 | 148 | from ddtrace_graphql import patch 149 | patch(span_kwargs=dict(service='foo.graphql')) 150 | 151 | 152 | .. code-block:: python 153 | 154 | from ddtrace_graphql import traced_graphql 155 | traced_graphql(schema, query, span_kwargs=dict(resource='bar.resource')) 156 | 157 | 158 | 159 | span_callback 160 | ============= 161 | 162 | In case you want to postprocess trace span you may use ``span_callback`` 163 | argument. ``span_callback`` must be function with signature ``def callback(result=result, span=span)`` 164 | where ``result`` is graphql execution result or ``None`` in case of fatal error and span is trace span object 165 | (`ddtrace.span.Span `_). 166 | 167 | What is it good for? Unfortunately one cannot filter/alarm on span metrics resp. 168 | meta information even if those are numeric (why Datadog?) so you can use it to 169 | send metrics based on span, result attributes. 170 | 171 | .. code-block:: python 172 | 173 | from datadog import statsd 174 | from ddtrace_graphql import patch, CLIENT_ERROR, INVALID 175 | 176 | def callback(result, span): 177 | tags = ['resource:{}'.format(span.resource.replace(' ', '_'))] 178 | statsd.increment('{}.request'.format(span.service), tags=tags) 179 | if span.error: 180 | statsd.increment('{}.error'.format(span.service), tags=tags) 181 | elif span.get_metric(CLIENT_ERROR): 182 | statsd.increment('{}.{}'.format(span.service, CLIENT_ERROR), tags=tags) 183 | if span.get_metric(INVALID): 184 | statsd.increment('{}.{}'.format(span.service, INVALID), tags=tags) 185 | 186 | patch(span_callback=callback) 187 | 188 | 189 | ignore_exceptions 190 | ================= 191 | 192 | Some frameworks use exceptions to handle 404s etc. you may want to ignore some 193 | exceptions resp. not consider them server error. To do this you can supply 194 | `ignore_exceptions` argument as list of exception classes to ignore. 195 | `ignore_exceptions` will be used in python's `isinstance` thus you can ignore 196 | also using base classes. 197 | 198 | 199 | .. code-block:: python 200 | 201 | from ddtrace_graphql import patch 202 | patch(ignore_exceptions=(ObjectNotFound, PermissionsDenied)) 203 | 204 | 205 | .. code-block:: python 206 | 207 | from ddtrace_graphql import traced_graphql 208 | traced_graphql( 209 | schema, query, 210 | ignore_exceptions=(ObjectNotFound, PermissionsDenied)) 211 | 212 | 213 | Development 214 | =========== 215 | 216 | Install from source in development mode 217 | --------------------------------------- 218 | 219 | .. code-block:: bash 220 | 221 | $ git clone https://github.com/beezz/ddtrace-graphql.git 222 | $ pip install --editable ddtrace-graphql[test] 223 | 224 | 225 | Run tests 226 | --------- 227 | 228 | .. code-block:: bash 229 | 230 | $ cd ddtrace-graphql 231 | $ tox 232 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.2.0 2 | -------------------------------------------------------------------------------- /ddtrace_graphql/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | To trace all GraphQL requests, patch the library like so:: 3 | 4 | from ddtrace_graphql import patch 5 | patch() 6 | 7 | from graphql import graphql 8 | result = graphql(schema, query) 9 | 10 | 11 | If you do not want to monkeypatch ``graphql.graphql`` function or want to trace 12 | only certain calls you can use the ``traced_graphql`` function:: 13 | 14 | from ddtrace_graphql import traced_graphql 15 | traced_graphql(schema, query) 16 | """ 17 | 18 | 19 | from ddtrace.contrib.util import require_modules 20 | 21 | required_modules = ['graphql'] 22 | 23 | with require_modules(required_modules) as missing_modules: 24 | if not missing_modules: 25 | from .base import ( 26 | TracedGraphQLSchema, traced_graphql, 27 | TYPE, SERVICE, QUERY, ERRORS, INVALID, RES_NAME, DATA_EMPTY, 28 | CLIENT_ERROR 29 | ) 30 | from .patch import patch, unpatch 31 | __all__ = [ 32 | 'TracedGraphQLSchema', 33 | 'patch', 'unpatch', 'traced_graphql', 34 | 'TYPE', 'SERVICE', 'QUERY', 'ERRORS', 'INVALID', 35 | 'RES_NAME', 'DATA_EMPTY', 'CLIENT_ERROR', 36 | ] 37 | 38 | -------------------------------------------------------------------------------- /ddtrace_graphql/base.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import ddtrace 5 | import graphql 6 | from ddtrace.ext import errors as ddtrace_errors 7 | 8 | from ddtrace_graphql import utils 9 | 10 | logger = logging.getLogger(__name__) 11 | _graphql = graphql.graphql 12 | 13 | 14 | TYPE = 'graphql' 15 | QUERY = 'query' 16 | ERRORS = 'errors' 17 | INVALID = 'invalid' 18 | CLIENT_ERROR = 'client_error' 19 | DATA_EMPTY = 'data_empty' 20 | RES_NAME = 'graphql.graphql' 21 | # 22 | SERVICE_ENV_VAR = 'DDTRACE_GRAPHQL_SERVICE' 23 | SERVICE = 'graphql' 24 | 25 | 26 | class TracedGraphQLSchema(graphql.GraphQLSchema): 27 | def __init__(self, *args, **kwargs): 28 | if 'datadog_tracer' in kwargs: 29 | self.datadog_tracer = kwargs.pop('datadog_tracer') 30 | logger.debug( 31 | 'For schema %s using own tracer %s', 32 | self, self.datadog_tracer) 33 | super(TracedGraphQLSchema, self).__init__(*args, **kwargs) 34 | 35 | 36 | def traced_graphql_wrapped( 37 | func, 38 | args, 39 | kwargs, 40 | span_kwargs=None, 41 | span_callback=None, 42 | ignore_exceptions=(), 43 | ): 44 | """ 45 | Wrapper for graphql.graphql function. 46 | """ 47 | # allow schemas their own tracer with fall-back to the global 48 | schema = args[0] 49 | tracer = getattr(schema, 'datadog_tracer', ddtrace.tracer) 50 | 51 | if not tracer.enabled: 52 | return func(*args, **kwargs) 53 | 54 | query = utils.get_query_string(args, kwargs) 55 | 56 | _span_kwargs = { 57 | 'name': RES_NAME, 58 | 'span_type': TYPE, 59 | 'service': os.getenv(SERVICE_ENV_VAR, SERVICE), 60 | 'resource': utils.resolve_query_res(query) 61 | } 62 | _span_kwargs.update(span_kwargs or {}) 63 | 64 | with tracer.trace(**_span_kwargs) as span: 65 | span.set_tag(QUERY, query) 66 | result = None 67 | try: 68 | result = func(*args, **kwargs) 69 | return result 70 | finally: 71 | # `span.error` must be integer 72 | span.error = int(result is None) 73 | 74 | if result is not None: 75 | 76 | span.error = 0 77 | if result.errors: 78 | span.set_tag( 79 | ERRORS, 80 | utils.format_errors(result.errors)) 81 | span.set_tag( 82 | ddtrace_errors.ERROR_STACK, 83 | utils.format_errors_traceback(result.errors)) 84 | span.set_tag( 85 | ddtrace_errors.ERROR_MSG, 86 | utils.format_errors_msg(result.errors)) 87 | span.set_tag( 88 | ddtrace_errors.ERROR_TYPE, 89 | utils.format_errors_type(result.errors)) 90 | 91 | span.error = int(utils.is_server_error( 92 | result, 93 | ignore_exceptions, 94 | )) 95 | 96 | span.set_metric( 97 | CLIENT_ERROR, 98 | int(bool(not span.error and result.errors)) 99 | ) 100 | span.set_metric(INVALID, int(result.invalid)) 101 | span.set_metric(DATA_EMPTY, int(result.data is None)) 102 | 103 | if span_callback is not None: 104 | span_callback(result=result, span=span) 105 | 106 | 107 | def traced_graphql( 108 | *args, 109 | span_kwargs=None, 110 | span_callback=None, 111 | ignore_exceptions=(), 112 | **kwargs 113 | ): 114 | return traced_graphql_wrapped( 115 | _graphql, args, kwargs, 116 | span_kwargs=span_kwargs, 117 | span_callback=span_callback, 118 | ignore_exceptions=ignore_exceptions 119 | ) 120 | -------------------------------------------------------------------------------- /ddtrace_graphql/patch.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tracing for the graphql-core library. 3 | 4 | https://github.com/graphql-python/graphql-core 5 | """ 6 | 7 | import logging 8 | import os 9 | 10 | import graphql 11 | import graphql.backend.core 12 | import wrapt 13 | from ddtrace.util import unwrap 14 | 15 | from ddtrace_graphql.base import traced_graphql_wrapped 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def patch(span_kwargs=None, span_callback=None, ignore_exceptions=()): 21 | """ 22 | Monkeypatches graphql-core library to trace graphql calls execution. 23 | """ 24 | 25 | def wrapper(func, _, args, kwargs): 26 | return traced_graphql_wrapped( 27 | func, 28 | args, 29 | kwargs, 30 | span_kwargs=span_kwargs, 31 | span_callback=span_callback, 32 | ignore_exceptions=ignore_exceptions, 33 | ) 34 | 35 | logger.debug("Patching `graphql.graphql` function.") 36 | 37 | wrapt.wrap_function_wrapper(graphql, "graphql", wrapper) 38 | 39 | logger.debug("Patching `graphql.backend.core.execute_and_validate` function.") 40 | 41 | wrapt.wrap_function_wrapper(graphql.backend.core, "execute_and_validate", wrapper) 42 | 43 | 44 | def unpatch(): 45 | logger.debug("Unpatching `graphql.graphql` function.") 46 | unwrap(graphql, "graphql") 47 | logger.debug("Unpatching `graphql.backend.core.execute_and_validate` function.") 48 | unwrap(graphql.backend.core, "execute_and_validate") 49 | -------------------------------------------------------------------------------- /ddtrace_graphql/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import traceback 4 | from io import StringIO 5 | 6 | from graphql.error import GraphQLError, format_error 7 | from graphql.language.ast import Document 8 | 9 | 10 | def get_request_string(args, kwargs): 11 | """ 12 | Given ``args``, ``kwargs`` of original function, returns request string. 13 | """ 14 | return args[1] if len(args) > 1 else kwargs.get('request_string') 15 | 16 | 17 | def get_query_string(args, kwargs): 18 | """ 19 | Given ``args``, ``kwargs`` of original function, returns query as string. 20 | """ 21 | rs = get_request_string(args, kwargs) 22 | return rs.loc.source.body if isinstance(rs, Document) else rs 23 | 24 | 25 | def is_server_error(result, ignore_exceptions): 26 | """ 27 | Determines from ``result`` if server error occured. 28 | 29 | Based on error handling done here https://bit.ly/2JamxWF 30 | """ 31 | errors = None if result.errors is None else [ 32 | error for error in result.errors 33 | if not isinstance(original_error(error), ignore_exceptions) 34 | ] 35 | return bool( 36 | ( 37 | errors 38 | and not result.invalid 39 | ) 40 | or 41 | ( 42 | errors 43 | and result.invalid 44 | and len(result.errors) == 1 45 | and not isinstance(result.errors[0], GraphQLError) 46 | ) 47 | ) 48 | 49 | 50 | def original_error(err): 51 | """ 52 | Returns original exception from graphql exception wrappers. 53 | 54 | graphql-core wraps exceptions that occurs on resolvers into special type 55 | with ``original_error`` attribute, which contains the real exception. 56 | """ 57 | return err.original_error if hasattr(err, 'original_error') else err 58 | 59 | 60 | def format_errors(errors): 61 | """ 62 | Formats list of exceptions, ``errors``, into json-string. 63 | 64 | ``GraphQLError exceptions`` contain additional information like line and 65 | column number, where the exception at query resolution happened. This 66 | method tries to extract that information. 67 | """ 68 | return json.dumps( 69 | [ 70 | # fix for graphql-core==1.x 71 | format_error(err) if hasattr(err, 'message') else str(err) 72 | for err in errors 73 | ], 74 | indent=2, 75 | ) 76 | 77 | 78 | def format_error_traceback(error, limit=20): 79 | """ 80 | Returns ``limit`` lines of ``error``s exception traceback. 81 | """ 82 | buffer_file = StringIO() 83 | traceback.print_exception( 84 | type(error), 85 | error, 86 | error.__traceback__, 87 | file=buffer_file, 88 | limit=limit, 89 | ) 90 | return buffer_file.getvalue() 91 | 92 | 93 | def format_errors_traceback(errors): 94 | """ 95 | Concatenates traceback strings from list of exceptions in ``errors``. 96 | """ 97 | return "\n\n".join([ 98 | format_error_traceback(original_error(error)) 99 | for error in errors if isinstance(error, Exception) 100 | ]) 101 | 102 | 103 | def _err_msg(error): 104 | return str(original_error(error)) 105 | 106 | 107 | def format_errors_msg(errors): 108 | """ 109 | Formats error message as json string from list of exceptions ``errors``. 110 | """ 111 | return _err_msg(errors[0]) if len(errors) == 1 else json.dumps( 112 | [ 113 | _err_msg(error) 114 | for error in errors if isinstance(error, Exception) 115 | ], 116 | indent=2 117 | ) 118 | 119 | 120 | def _err_type(error): 121 | return type(original_error(error)).__name__ 122 | 123 | 124 | def format_errors_type(errors): 125 | """ 126 | Formats error types as json string from list of exceptions ``errors``. 127 | """ 128 | return _err_type(errors[0]) if len(errors) == 1 else json.dumps( 129 | [ 130 | _err_type(error) 131 | for error in errors if isinstance(error, Exception) 132 | ], 133 | indent=2 134 | ) 135 | 136 | 137 | def resolve_query_res(query): 138 | """ 139 | Extracts resource name from ``query`` string. 140 | """ 141 | # split by '(' for queries with arguments 142 | # split by '{' for queries without arguments 143 | # rather full query than empty resource name 144 | return re.split('[({]', query, 1)[0].strip() or query 145 | -------------------------------------------------------------------------------- /screenshots/query.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beezz/ddtrace-graphql/503d9172df9e61c650d8dabecb941700e1289467/screenshots/query.png -------------------------------------------------------------------------------- /screenshots/service.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beezz/ddtrace-graphql/503d9172df9e61c650d8dabecb941700e1289467/screenshots/service.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.2.0 3 | 4 | [metadata] 5 | description-file = README.rst 6 | 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from codecs import open 3 | from os import path 4 | 5 | here = path.abspath(path.dirname(__file__)) 6 | 7 | with open(path.join(here, "README.rst"), encoding="utf-8") as f: 8 | long_description = f.read() 9 | 10 | with open(path.join(here, "VERSION"), encoding="utf-8") as f: 11 | version = f.read() 12 | 13 | 14 | setup( 15 | name="ddtrace-graphql", # Required 16 | version=version, # Required 17 | description="Python library for tracing graphql calls with Datadog", 18 | long_description=long_description, # Optional 19 | url="https://github.com/beezz/ddtrace-graphql", # Optional 20 | author="Michal Kuffa", # Optional 21 | author_email="michal@bynder.com", # Optional 22 | classifiers=[ # Optional 23 | "Intended Audience :: Developers", 24 | "Topic :: Software Development :: Debuggers", 25 | "License :: OSI Approved :: MIT License", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.5", 28 | "Programming Language :: Python :: 3.6", 29 | ], 30 | keywords="tracing datadog graphql graphene", 31 | packages=find_packages(exclude=["tests"]), # Required 32 | install_requires=["ddtrace", "graphql-core", "wrapt"], 33 | extras_require={"test": ["tox", "pytest", "pytest-cov"]}, 34 | ) 35 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beezz/ddtrace-graphql/503d9172df9e61c650d8dabecb941700e1289467/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_graphql.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | import graphql 5 | from ddtrace.encoding import JSONEncoder, MsgpackEncoder 6 | from ddtrace.ext import errors as ddtrace_errors 7 | from ddtrace.tracer import Tracer 8 | from ddtrace.writer import AgentWriter 9 | from graphql import GraphQLField, GraphQLObjectType, GraphQLString 10 | from graphql.execution import ExecutionResult 11 | from graphql.language.parser import parse as graphql_parse 12 | from graphql.language.source import Source as GraphQLSource 13 | from wrapt import FunctionWrapper 14 | 15 | import ddtrace_graphql 16 | from ddtrace_graphql import ( 17 | DATA_EMPTY, ERRORS, INVALID, QUERY, SERVICE, CLIENT_ERROR, 18 | TracedGraphQLSchema, patch, traced_graphql, unpatch 19 | ) 20 | from ddtrace_graphql.base import traced_graphql_wrapped 21 | 22 | 23 | class DummyWriter(AgentWriter): 24 | """ 25 | # NB: This is coy fo DummyWriter class from ddtraces tests suite 26 | 27 | DummyWriter is a small fake writer used for tests. not thread-safe. 28 | """ 29 | 30 | def __init__(self): 31 | # original call 32 | super(DummyWriter, self).__init__() 33 | # dummy components 34 | self.spans = [] 35 | self.traces = [] 36 | self.services = {} 37 | self.json_encoder = JSONEncoder() 38 | self.msgpack_encoder = MsgpackEncoder() 39 | 40 | def write(self, spans=None, services=None): 41 | if spans: 42 | # the traces encoding expect a list of traces so we 43 | # put spans in a list like we do in the real execution path 44 | # with both encoders 45 | trace = [spans] 46 | self.json_encoder.encode_traces(trace) 47 | self.msgpack_encoder.encode_traces(trace) 48 | self.spans += spans 49 | self.traces += trace 50 | 51 | if services: 52 | self.json_encoder.encode_services(services) 53 | self.msgpack_encoder.encode_services(services) 54 | self.services.update(services) 55 | 56 | def pop(self): 57 | # dummy method 58 | s = self.spans 59 | self.spans = [] 60 | return s 61 | 62 | def pop_traces(self): 63 | # dummy method 64 | traces = self.traces 65 | self.traces = [] 66 | return traces 67 | 68 | def pop_services(self): 69 | # dummy method 70 | s = self.services 71 | self.services = {} 72 | return s 73 | 74 | 75 | def get_dummy_tracer(): 76 | tracer = Tracer() 77 | tracer.writer = DummyWriter() 78 | return tracer 79 | 80 | 81 | def get_traced_schema(tracer=None, query=None, resolver=None): 82 | resolver = resolver or (lambda *_: 'world') 83 | tracer = tracer or get_dummy_tracer() 84 | query = query or GraphQLObjectType( 85 | name='RootQueryType', 86 | fields={ 87 | 'hello': GraphQLField( 88 | type=GraphQLString, 89 | resolver=resolver, 90 | ) 91 | } 92 | ) 93 | return tracer, TracedGraphQLSchema(query=query, datadog_tracer=tracer) 94 | 95 | 96 | class TestGraphQL: 97 | 98 | def test_unpatch(self): 99 | gql = graphql.graphql 100 | unpatch() 101 | assert gql == graphql.graphql 102 | assert not isinstance(graphql.graphql, FunctionWrapper) 103 | patch() 104 | assert isinstance(graphql.graphql, FunctionWrapper) 105 | 106 | tracer, schema = get_traced_schema() 107 | graphql.graphql(schema, '{ hello }') 108 | span = tracer.writer.pop()[0] 109 | 110 | unpatch() 111 | assert gql == graphql.graphql 112 | 113 | cb_args = {} 114 | def test_cb(**kwargs): 115 | cb_args.update(kwargs) 116 | patch(span_callback=test_cb) 117 | assert isinstance(graphql.graphql, FunctionWrapper) 118 | 119 | result = graphql.graphql(schema, '{ hello }') 120 | span = tracer.writer.pop()[0] 121 | assert cb_args['span'] is span 122 | assert cb_args['result'] is result 123 | 124 | unpatch() 125 | assert gql == graphql.graphql 126 | 127 | 128 | 129 | def test_invalid(self): 130 | tracer, schema = get_traced_schema() 131 | result = traced_graphql(schema, '{ hello world }') 132 | span = tracer.writer.pop()[0] 133 | assert span.get_metric(INVALID) == result.invalid == 1 134 | assert span.get_metric(DATA_EMPTY) == 1 135 | assert span.error == 0 136 | 137 | result = traced_graphql(schema, '{ hello }') 138 | span = tracer.writer.pop()[0] 139 | assert span.get_metric(INVALID) == result.invalid == 0 140 | assert span.error == 0 141 | 142 | def test_unhandled_exception(self): 143 | 144 | def exc_resolver(*args): 145 | raise Exception('Testing stuff') 146 | 147 | tracer, schema = get_traced_schema(resolver=exc_resolver) 148 | result = traced_graphql(schema, '{ hello }') 149 | span = tracer.writer.pop()[0] 150 | assert span.get_metric(INVALID) == 0 151 | assert span.error == 1 152 | assert span.get_metric(CLIENT_ERROR) == 0 153 | assert span.get_metric(DATA_EMPTY) == 0 154 | 155 | error_stack = span.get_tag(ddtrace_errors.ERROR_STACK) 156 | assert 'Testing stuff' in error_stack 157 | assert 'Traceback' in error_stack 158 | 159 | error_msg = span.get_tag(ddtrace_errors.ERROR_MSG) 160 | assert 'Testing stuff' in error_msg 161 | 162 | error_type = span.get_tag(ddtrace_errors.ERROR_TYPE) 163 | assert 'Exception' in error_type 164 | 165 | try: 166 | raise Exception('Testing stuff') 167 | except Exception as exc: 168 | _error = exc 169 | 170 | def _tg(*args, **kwargs): 171 | def func(*args, **kwargs): 172 | return ExecutionResult( 173 | errors=[_error], 174 | invalid=True, 175 | ) 176 | return traced_graphql_wrapped(func, args, kwargs) 177 | 178 | tracer, schema = get_traced_schema(resolver=exc_resolver) 179 | result = _tg(schema, '{ hello }') 180 | 181 | span = tracer.writer.pop()[0] 182 | assert span.get_metric(INVALID) == 1 183 | assert span.error == 1 184 | assert span.get_metric(DATA_EMPTY) == 1 185 | 186 | error_stack = span.get_tag(ddtrace_errors.ERROR_STACK) 187 | assert 'Testing stuff' in error_stack 188 | assert 'Traceback' in error_stack 189 | 190 | def test_not_server_error(self): 191 | class TestException(Exception): 192 | pass 193 | 194 | def exc_resolver(*args): 195 | raise TestException('Testing stuff') 196 | 197 | tracer, schema = get_traced_schema(resolver=exc_resolver) 198 | result = traced_graphql( 199 | schema, 200 | '{ hello }', 201 | ignore_exceptions=(TestException), 202 | ) 203 | span = tracer.writer.pop()[0] 204 | assert span.get_metric(INVALID) == 0 205 | assert span.error == 0 206 | assert span.get_metric(DATA_EMPTY) == 0 207 | assert span.get_metric(CLIENT_ERROR) == 1 208 | 209 | def test_request_string_resolve(self): 210 | query = '{ hello }' 211 | 212 | # string as args[1] 213 | tracer, schema = get_traced_schema() 214 | traced_graphql(schema, query) 215 | span = tracer.writer.pop()[0] 216 | assert span.get_tag(QUERY) == query 217 | 218 | # string as kwargs.get('request_string') 219 | tracer, schema = get_traced_schema() 220 | traced_graphql(schema, request_string=query) 221 | span = tracer.writer.pop()[0] 222 | assert span.get_tag(QUERY) == query 223 | 224 | # ast as args[1] 225 | tracer, schema = get_traced_schema() 226 | ast_query = graphql_parse(GraphQLSource(query, 'Test Request')) 227 | traced_graphql(schema, ast_query) 228 | span = tracer.writer.pop()[0] 229 | assert span.get_tag(QUERY) == query 230 | 231 | # ast as kwargs.get('request_string') 232 | tracer, schema = get_traced_schema() 233 | ast_query = graphql_parse(GraphQLSource(query, 'Test Request')) 234 | traced_graphql(schema, request_string=ast_query) 235 | span = tracer.writer.pop()[0] 236 | assert span.get_tag(QUERY) == query 237 | 238 | @staticmethod 239 | def test_query_tag(): 240 | query = '{ hello }' 241 | tracer, schema = get_traced_schema() 242 | traced_graphql(schema, query) 243 | span = tracer.writer.pop()[0] 244 | assert span.get_tag(QUERY) == query 245 | 246 | # test query also for error span, just in case 247 | query = '{ hello world }' 248 | tracer, schema = get_traced_schema() 249 | traced_graphql(schema, query) 250 | span = tracer.writer.pop()[0] 251 | assert span.get_tag(QUERY) == query 252 | 253 | @staticmethod 254 | def test_errors_tag(): 255 | query = '{ hello }' 256 | tracer, schema = get_traced_schema() 257 | result = traced_graphql(schema, query) 258 | span = tracer.writer.pop()[0] 259 | assert not span.get_tag(ERRORS) 260 | assert result.errors is span.get_tag(ERRORS) is None 261 | 262 | query = '{ hello world }' 263 | result = traced_graphql(schema, query) 264 | span = tracer.writer.pop()[0] 265 | span_errors = span.get_tag(ERRORS) 266 | assert span_errors 267 | _se = json.loads(span_errors) 268 | assert len(_se) == len(result.errors) == 1 269 | assert 'message' in _se[0] 270 | assert 'line' in _se[0]['locations'][0] 271 | assert 'column' in _se[0]['locations'][0] 272 | 273 | @staticmethod 274 | def test_resource(): 275 | query = '{ hello world }' 276 | tracer, schema = get_traced_schema() 277 | traced_graphql(schema, query) 278 | span = tracer.writer.pop()[0] 279 | assert span.resource == query 280 | 281 | query = 'mutation fnCall(args: Args) { }' 282 | traced_graphql(schema, query) 283 | span = tracer.writer.pop()[0] 284 | assert span.resource == 'mutation fnCall' 285 | 286 | query = 'mutation fnCall { }' 287 | traced_graphql(schema, query) 288 | span = tracer.writer.pop()[0] 289 | assert span.resource == 'mutation fnCall' 290 | 291 | query = 'mutation fnCall { }' 292 | traced_graphql(schema, query, span_kwargs={'resource': 'test'}) 293 | span = tracer.writer.pop()[0] 294 | assert span.resource == 'test' 295 | 296 | @staticmethod 297 | def test_span_callback(): 298 | cb_args = {} 299 | def test_cb(result, span): 300 | cb_args.update(dict(result=result, span=span)) 301 | query = '{ hello world }' 302 | tracer, schema = get_traced_schema() 303 | result = traced_graphql(schema, query, span_callback=test_cb) 304 | span = tracer.writer.pop()[0] 305 | assert cb_args['span'] is span 306 | assert cb_args['result'] is result 307 | 308 | @staticmethod 309 | def test_span_kwargs_overrides(): 310 | query = '{ hello }' 311 | tracer, schema = get_traced_schema() 312 | 313 | traced_graphql(schema, query, span_kwargs={'resource': 'test'}) 314 | span = tracer.writer.pop()[0] 315 | assert span.resource == 'test' 316 | 317 | traced_graphql( 318 | schema, 319 | query, 320 | span_kwargs={ 321 | 'service': 'test', 322 | 'name': 'test', 323 | } 324 | ) 325 | span = tracer.writer.pop()[0] 326 | assert span.service == 'test' 327 | assert span.name == 'test' 328 | assert span.resource == '{ hello }' 329 | 330 | @staticmethod 331 | def test_service_from_env(): 332 | query = '{ hello }' 333 | tracer, schema = get_traced_schema() 334 | 335 | global traced_graphql 336 | traced_graphql(schema, query) 337 | span = tracer.writer.pop()[0] 338 | assert span.service == SERVICE 339 | 340 | os.environ['DDTRACE_GRAPHQL_SERVICE'] = 'test.test' 341 | 342 | traced_graphql(schema, query) 343 | span = tracer.writer.pop()[0] 344 | assert span.service == 'test.test' 345 | 346 | @staticmethod 347 | def test_tracer_disabled(): 348 | query = '{ hello world }' 349 | tracer, schema = get_traced_schema() 350 | tracer.enabled = False 351 | traced_graphql(schema, query) 352 | assert not tracer.writer.pop() 353 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37 3 | 4 | [testenv] 5 | deps= 6 | .[test] 7 | commands=python -m pytest tests --cov-report term-missing --cov ddtrace_graphql -v 8 | --------------------------------------------------------------------------------