Skip to content

Commit 31c7cb5

Browse files
committed
feat(graphql): add graphql-core integration
1 parent 8572bbc commit 31c7cb5

16 files changed

+881
-0
lines changed

.circleci/config.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,13 @@ jobs:
664664
- run_tox_scenario:
665665
pattern: '^gevent_contrib-'
666666

667+
graphql:
668+
<<: *machine_executor
669+
steps:
670+
- run_test:
671+
pattern: "graphql"
672+
snapshot: true
673+
667674
grpc:
668675
<<: *machine_executor
669676
parallelism: 7
@@ -1069,6 +1076,7 @@ requires_tests: &requires_tests
10691076
- flask
10701077
- futures
10711078
- gevent
1079+
- graphql
10721080
- grpc
10731081
- httplib
10741082
- httpx
@@ -1160,6 +1168,7 @@ workflows:
11601168
- flask: *requires_base_venvs
11611169
- futures: *requires_base_venvs
11621170
- gevent: *requires_base_venvs
1171+
- graphql: *requires_base_venvs
11631172
- grpc: *requires_base_venvs
11641173
- httplib: *requires_base_venvs
11651174
- httpx: *requires_base_venvs

ddtrace/_monkey.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"algoliasearch": True,
3535
"futures": True,
3636
"gevent": True,
37+
"graphql": True,
3738
"grpc": True,
3839
"httpx": True,
3940
"mongoengine": True,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
This integration instruments ``graphql-core`` queries. Version 2.0 and above are supported.
3+
4+
5+
Enabling
6+
~~~~~~~~
7+
8+
The graphql integration is enabled automatically when using
9+
:ref:`ddtrace-run <ddtracerun>` or :func:`patch_all() <ddtrace.patch_all>`.
10+
11+
Or use :func:`patch() <ddtrace.patch>` to manually enable the integration::
12+
13+
from ddtrace import patch
14+
patch(graphql=True)
15+
import graphql
16+
...
17+
18+
Global Configuration
19+
~~~~~~~~~~~~~~~~~~~~
20+
21+
.. py:data:: ddtrace.config.graphql["service"]
22+
23+
The service name reported by default for graphql instances.
24+
25+
This option can also be set with the ``DD_SERVICE`` environment
26+
variable.
27+
28+
Default: ``"graphql"``
29+
30+
.. py:data:: ddtrace.config.graphql["resolvers_enabled"]
31+
32+
To enable ``graphql.resolve`` spans set ``DD_TRACE_GRAPHQL_RESOLVERS_ENABLED`` to True
33+
34+
Default: ``False``
35+
36+
37+
To configure the graphql integration using the
38+
``Pin`` API::
39+
40+
from ddtrace import Pin
41+
import graphql
42+
43+
Pin.override(graphql, service="mygraphql")
44+
"""
45+
from ...internal.utils.importlib import require_modules
46+
47+
48+
required_modules = ["graphql"]
49+
50+
with require_modules(required_modules) as missing_modules:
51+
if not missing_modules:
52+
from .patch import graphql_version
53+
from .patch import patch
54+
from .patch import unpatch
55+
56+
__all__ = ["patch", "unpatch", "graphql_version"]

ddtrace/contrib/graphql/patch.py

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import os
2+
import re
3+
import sys
4+
from typing import TYPE_CHECKING
5+
6+
7+
if TYPE_CHECKING:
8+
from typing import Callable
9+
from typing import Dict
10+
from typing import Iterable
11+
from typing import List
12+
from typing import Tuple
13+
from typing import Union
14+
15+
from ddtrace import Span
16+
17+
import graphql
18+
from graphql import MiddlewareManager
19+
from graphql.error import GraphQLError
20+
from graphql.execution import ExecutionResult
21+
from graphql.language.source import Source
22+
23+
from ddtrace import config
24+
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY
25+
from ddtrace.constants import ERROR_MSG
26+
from ddtrace.constants import ERROR_STACK
27+
from ddtrace.constants import ERROR_TYPE
28+
from ddtrace.constants import SPAN_MEASURED_KEY
29+
from ddtrace.internal.compat import stringify
30+
from ddtrace.internal.utils import ArgumentError
31+
from ddtrace.internal.utils import get_argument_value
32+
from ddtrace.internal.utils import set_argument_value
33+
from ddtrace.internal.utils.formats import asbool
34+
from ddtrace.internal.utils.version import parse_version
35+
from ddtrace.internal.wrapping import unwrap
36+
from ddtrace.internal.wrapping import wrap
37+
from ddtrace.pin import Pin
38+
39+
from .. import trace_utils
40+
from ...ext import SpanTypes
41+
42+
43+
_graphql_version_str = getattr(graphql, "__version__")
44+
graphql_version = parse_version(_graphql_version_str)
45+
46+
if graphql_version < (3, 0):
47+
from graphql.language.ast import Document
48+
else:
49+
from graphql.language.ast import DocumentNode as Document
50+
51+
52+
config._add(
53+
"graphql",
54+
dict(
55+
_default_service="graphql",
56+
resolvers_enabled=asbool(os.getenv("DD_TRACE_GRAPHQL_RESOLVERS_ENABLED", default=False)),
57+
),
58+
)
59+
60+
61+
def patch():
62+
if getattr(graphql, "_datadog_patch", False):
63+
return
64+
setattr(graphql, "_datadog_patch", True)
65+
Pin().onto(graphql)
66+
67+
for module_str, func_name, wrapper in _get_patching_candidates():
68+
_update_patching(wrap, module_str, func_name, wrapper)
69+
70+
71+
def unpatch():
72+
if not getattr(graphql, "_datadog_patch", False) or graphql_version < (2, 0):
73+
return
74+
75+
for module_str, func_name, wrapper in _get_patching_candidates():
76+
_update_patching(unwrap, module_str, func_name, wrapper)
77+
78+
setattr(graphql, "_datadog_patch", False)
79+
80+
81+
def _get_patching_candidates():
82+
if graphql_version < (3, 0):
83+
return [
84+
("graphql.graphql", "execute_graphql", _traced_query),
85+
("graphql.language.parser", "parse", _traced_parse),
86+
("graphql.validation.validation", "validate", _traced_validate),
87+
("graphql.execution.executor", "execute", _traced_execute),
88+
]
89+
return [
90+
("graphql.graphql", "graphql_impl", _traced_query),
91+
("graphql.language.parser", "parse", _traced_parse),
92+
("graphql.validation.validate", "validate", _traced_validate),
93+
("graphql.execution.execute", "execute", _traced_execute),
94+
]
95+
96+
97+
def _update_patching(operation, module_str, func_name, wrapper):
98+
module = sys.modules[module_str]
99+
func = getattr(module, func_name)
100+
operation(func, wrapper)
101+
102+
103+
def _traced_parse(func, args, kwargs):
104+
pin = Pin.get_from(graphql)
105+
if not pin or not pin.enabled():
106+
return func(*args, **kwargs)
107+
108+
# If graphql.parse() is called outside graphql.graphql(), graphql.parse will
109+
# be a top level span. Thereforce we must explicitly set the service name.
110+
with pin.tracer.trace(
111+
name="graphql.parse",
112+
service=trace_utils.int_service(pin, config.graphql),
113+
span_type=SpanTypes.GRAPHQL,
114+
):
115+
return func(*args, **kwargs)
116+
117+
118+
def _traced_validate(func, args, kwargs):
119+
pin = Pin.get_from(graphql)
120+
if not pin or not pin.enabled():
121+
return func(*args, **kwargs)
122+
123+
# If graphql.parse() is called outside graphql.graphql(), graphql.parse will
124+
# be a top level span. Thereforce we must explicitly set the service name.
125+
with pin.tracer.trace(
126+
name="graphql.validate",
127+
service=trace_utils.int_service(pin, config.graphql),
128+
span_type=SpanTypes.GRAPHQL,
129+
) as span:
130+
errors = func(*args, **kwargs)
131+
_set_span_errors(errors, span)
132+
return errors
133+
134+
135+
def _traced_execute(func, args, kwargs):
136+
pin = Pin.get_from(graphql)
137+
if not pin or not pin.enabled():
138+
return func(*args, **kwargs)
139+
140+
if config.graphql.resolvers_enabled:
141+
# patch resolvers
142+
args, kwargs = _inject_trace_middleware_to_args(_resolver_middleware, args, kwargs)
143+
144+
# set resource name
145+
if graphql_version < (3, 0):
146+
document = get_argument_value(args, kwargs, 1, "document_ast")
147+
else:
148+
document = get_argument_value(args, kwargs, 1, "document")
149+
resource = _get_source_str(document)
150+
151+
with pin.tracer.trace(
152+
name="graphql.execute",
153+
resource=resource,
154+
service=trace_utils.int_service(pin, config.graphql),
155+
span_type=SpanTypes.GRAPHQL,
156+
) as span:
157+
result = func(*args, **kwargs)
158+
if isinstance(result, ExecutionResult):
159+
# set error tags if the result contains a list of GraphqlErrors, skip if it's a promise
160+
# TODO: support promises in graphql-core==2
161+
_set_span_errors(result.errors, span)
162+
return result
163+
164+
165+
def _traced_query(func, args, kwargs):
166+
pin = Pin.get_from(graphql)
167+
if not pin or not pin.enabled():
168+
return func(*args, **kwargs)
169+
170+
# set resource name
171+
source = get_argument_value(args, kwargs, 1, "source")
172+
resource = _get_source_str(source)
173+
174+
with pin.tracer.trace(
175+
name="graphql.query",
176+
resource=resource,
177+
service=trace_utils.int_service(pin, config.graphql),
178+
span_type=SpanTypes.GRAPHQL,
179+
) as span:
180+
_init_span(span)
181+
result = func(*args, **kwargs)
182+
if isinstance(result, ExecutionResult):
183+
# set error tags if the result contains a list of GraphqlErrors, skip if it's a promise
184+
_set_span_errors(result.errors, span)
185+
return result
186+
187+
188+
def _init_span(span):
189+
# type: (Span) -> None
190+
"""mark span as measured and set sample rate"""
191+
span.set_tag(SPAN_MEASURED_KEY)
192+
sample_rate = config.graphql.get_analytics_sample_rate()
193+
if sample_rate is not None:
194+
span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, sample_rate)
195+
196+
197+
def _resolver_middleware(next_middleware, root, info, **args):
198+
"""
199+
trace middleware which wraps the resolvers of graphql fields.
200+
Note - graphql middlewares can not be a partial. It must be a class or a function.
201+
"""
202+
pin = Pin.get_from(graphql)
203+
if not pin or not pin.enabled():
204+
return next_middleware(root, info, **args)
205+
206+
with pin.tracer.trace(
207+
name="graphql.resolve",
208+
resource=info.field_name,
209+
span_type=SpanTypes.GRAPHQL,
210+
):
211+
return next_middleware(root, info, **args)
212+
213+
214+
def _inject_trace_middleware_to_args(trace_middleware, args, kwargs):
215+
# type: (Callable, Tuple, Dict) -> Tuple[Tuple, Dict]
216+
"""
217+
Adds a trace middleware to graphql.execute(..., middleware, ...)
218+
"""
219+
middlewares_arg = 8
220+
if graphql_version >= (3, 2):
221+
# middleware is the 10th argument graphql.execute(..) version 3.2+
222+
middlewares_arg = 9
223+
224+
# get middlewares from args or kwargs
225+
try:
226+
middlewares = get_argument_value(args, kwargs, middlewares_arg, "middleware") or []
227+
if isinstance(middlewares, MiddlewareManager):
228+
# First we must get the middlewares iterable from the MiddlewareManager then append
229+
# trace_middleware. For the trace_middleware to be called a new MiddlewareManager will
230+
# need to initialized. This is handled in graphql.execute():
231+
# https://github.com/graphql-python/graphql-core/blob/v3.2.1/src/graphql/execution/execute.py#L254
232+
middlewares = middlewares.middlewares # type: Iterable
233+
except ArgumentError:
234+
middlewares = []
235+
236+
# Note - graphql middlewares are called in reverse order
237+
# add trace_middleware to the end of the list to wrap the execution of resolver and all middlewares
238+
middlewares = list(middlewares) + [trace_middleware]
239+
240+
# update args and kwargs to contain trace_middleware
241+
args, kwargs = set_argument_value(args, kwargs, middlewares_arg, "middleware", middlewares)
242+
return args, kwargs
243+
244+
245+
def _get_source_str(obj):
246+
# type: (Union[str, Source, Document]) -> str
247+
"""
248+
Parses graphql Documents and Source objects to retrieve
249+
the graphql source input for a request.
250+
"""
251+
if isinstance(obj, str):
252+
source_str = obj
253+
elif isinstance(obj, Source):
254+
source_str = obj.body
255+
elif isinstance(obj, Document):
256+
source_str = obj.loc.source.body
257+
else:
258+
source_str = ""
259+
# remove new lines, tabs and extra whitespace from source_str
260+
return re.sub(r"\s+", " ", source_str).strip()
261+
262+
263+
def _set_span_errors(errors, span):
264+
# type: (List[GraphQLError], Span) -> None
265+
if not errors:
266+
# do nothing if the list of graphql errors is empty
267+
return
268+
269+
error_msgs = "\n".join([stringify(error) for error in errors])
270+
exc_type_str = "%s.%s" % (GraphQLError.__module__, GraphQLError.__name__)
271+
272+
span.error = 1
273+
span._set_str_tag(ERROR_MSG, error_msgs)
274+
span._set_str_tag(ERROR_TYPE, exc_type_str)
275+
span._set_str_tag(ERROR_STACK, "")

docs/index.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ contacting support.
9797
+--------------------------------------------------+---------------+----------------+
9898
| :ref:`grpc` | >= 1.12.0 | Yes [5]_ |
9999
+--------------------------------------------------+---------------+----------------+
100+
| :ref:`graphql-core` | >= 2.0.0 | Yes |
101+
+--------------------------------------------------+---------------+----------------+
100102
| :ref:`httplib` | \* | Yes |
101103
+--------------------------------------------------+---------------+----------------+
102104
| :ref:`httpx` | >= 0.14.0 | Yes |

docs/integrations.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,13 @@ gevent
189189
.. automodule:: ddtrace.contrib.gevent
190190

191191

192+
.. _graphql:
193+
194+
graphql
195+
^^^^^^^
196+
.. automodule:: ddtrace.contrib.graphql
197+
198+
192199
.. _grpc:
193200

194201
Grpc

0 commit comments

Comments
 (0)