Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,13 @@ jobs:
- run_tox_scenario:
pattern: '^gevent_contrib-'

graphql:
<<: *machine_executor
steps:
- run_test:
pattern: "graphql"
snapshot: true

grpc:
<<: *machine_executor
parallelism: 7
Expand Down Expand Up @@ -1070,6 +1077,7 @@ requires_tests: &requires_tests
- flask
- futures
- gevent
- graphql
- grpc
- httplib
- httpx
Expand Down Expand Up @@ -1161,6 +1169,7 @@ workflows:
- flask: *requires_base_venvs
- futures: *requires_base_venvs
- gevent: *requires_base_venvs
- graphql: *requires_base_venvs
- grpc: *requires_base_venvs
- httplib: *requires_base_venvs
- httpx: *requires_base_venvs
Expand Down
1 change: 1 addition & 0 deletions ddtrace/_monkey.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"algoliasearch": True,
"futures": True,
"gevent": True,
"graphql": True,
"grpc": True,
"httpx": True,
"mongoengine": True,
Expand Down
54 changes: 54 additions & 0 deletions ddtrace/contrib/graphql/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
The graphql integration instruments graphql-core queries. Version 2.0 and above are fully
supported.


Enabling
~~~~~~~~

The graphql integration is enabled automatically when using
:ref:`ddtrace-run <ddtracerun>` or :func:`patch_all() <ddtrace.patch_all>`.

Or use :func:`patch() <ddtrace.patch>` to manually enable the integration::

from ddtrace import patch
patch(graphql=True)
import graphql
....

Global Configuration
~~~~~~~~~~~~~~~~~~~~

.. py:data:: ddtrace.config.graphql["service"]

The service name reported by default for graphql instances.

This option can also be set with the ``DD_GRAPHQL_SERVICE`` environment
variable.

Default: ``"graphql"``


Instance Configuration
~~~~~~~~~~~~~~~~~~~~~~

To configure the graphql integration on a per-instance basis use the
``Pin`` API::

from ddtrace import Pin
import graphql

Pin.override(graphql, service="mygraphql")
"""
from ...internal.utils.importlib import require_modules


required_modules = ["graphql"]

with require_modules(required_modules) as missing_modules:
if not missing_modules:
from .patch import graphql_version
from .patch import patch
from .patch import unpatch

__all__ = ["patch", "unpatch", "graphql_version"]
190 changes: 190 additions & 0 deletions ddtrace/contrib/graphql/patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import os
import re
from typing import Any
from typing import Union

import graphql
from graphql.error import GraphQLError
from graphql.execution import ExecutionResult
from graphql.language.source import Source

from ddtrace import Span
from ddtrace import config
from ddtrace.constants import ANALYTICS_SAMPLE_RATE_KEY
from ddtrace.constants import SPAN_MEASURED_KEY
from ddtrace.internal.compat import stringify
from ddtrace.internal.utils import get_argument_value
from ddtrace.internal.utils.formats import asbool
from ddtrace.internal.utils.version import parse_version
from ddtrace.pin import Pin
from ddtrace.vendor.wrapt import wrap_function_wrapper as _w

from .. import trace_utils
from ...ext import SpanTypes


config._add("graphql", dict(_default_service="graphql"))


graphql_version_str = getattr(graphql, "__version__")
graphql_version = parse_version(graphql_version_str)

if graphql_version < (3, 0):
from graphql.language.ast import Document
else:
from graphql.language.ast import DocumentNode as Document


def patch():
if getattr(graphql, "_datadog_patch", False) or graphql_version < (2, 0):
return

if graphql_version < (2, 1):
# Patch functions used in graphql.graphql()
_w("graphql.graphql", "execute_graphql", _traced_operation("graphql.query"))
_w("graphql.graphql", "parse", _traced_operation("graphql.parse"))
_w("graphql.graphql", "validate", _traced_operation("graphql.validate"))
_w("graphql.graphql", "execute", _traced_operation("graphql.execute"))
# Patch execute functions exposed in the public api
_w("graphql.execution", "execute", _traced_operation("graphql.execute"))
_w("graphql", "execute", _traced_operation("graphql.execute"))
elif graphql_version < (3, 0):
# Patch functions used in graphql.graphql()
_w("graphql.graphql", "execute_graphql", _traced_operation("graphql.query"))
_w("graphql.backend.core", "parse", _traced_operation("graphql.parse"))
_w("graphql.backend.core", "validate", _traced_operation("graphql.validate"))
_w("graphql.backend.core", "execute", _traced_operation("graphql.execute"))
_w("graphql.execution.executor", "execute", _traced_operation("graphql.execute"))
# Patch execute functions exposed in the public api
_w("graphql.execution", "execute", _traced_operation("graphql.execute"))
_w("graphql", "execute", _traced_operation("graphql.execute"))
else:
# Patch functions used in graphql.graphql and graphql.graphql_sync
_w("graphql.graphql", "graphql_impl", _traced_operation("graphql.query"))
_w("graphql.graphql", "parse", _traced_operation("graphql.parse"))
_w("graphql.validation", "validate", _traced_operation("graphql.validate"))
_w("graphql.graphql", "execute", _traced_operation("graphql.execute"))
# Patch execute functions exposed in the public api
_w("graphql.execution", "execute", _traced_operation("graphql.execute"))
_w("graphql", "execute", _traced_operation("graphql.execute"))
if graphql_version > (3, 1):
_w("graphql", "execute_sync", _traced_operation("graphql.execute"))
_w("graphql.execution", "execute_sync", _traced_operation("graphql.execute"))

_patch_resolvers()

setattr(graphql, "_datadog_patch", True)
Pin().onto(graphql)


def unpatch():
pass


def _patch_resolvers():
if not asbool(os.getenv("DD_TRACE_GRAPHQL_PATCH_RESOLVERS", default=True)):
return
elif graphql_version < (3, 0):
_w("graphql.execution.executor", "resolve_field", _traced_operation("graphql.resolve"))
elif graphql_version < (3, 2):
_w("graphql.execution.execute", "ExecutionContext.resolve_field", _traced_operation("graphql.resolve"))
else:
# ExecutionContext.resolve_field was renamed to execute_field in graphql-core 3.2
_w("graphql.execution.execute", "ExecutionContext.execute_field", _traced_operation("graphql.resolve"))


def _traced_operation(span_name):
def _wrapper(func, instance, args, kwargs):
pin = Pin.get_from(graphql)
if not pin or not pin.enabled():
return func(*args, **kwargs)

resource = _get_resource(span_name, args, kwargs)
with pin.tracer.trace(
name=span_name,
resource=resource,
service=trace_utils.int_service(pin, config.graphql),
span_type=SpanTypes.WEB,
) as span:
_init_span(span)
result = func(*args, **kwargs)
_set_span_errors(result, span)
return result

return _wrapper


def _get_resource(span_name, f_args, f_kwargs):
if span_name == "graphql.query":
return _get_source_from_query(f_args, f_kwargs)
elif span_name == "graphql.execute":
return _get_source_from_execute(f_args, f_kwargs)
elif span_name == "graphql.resolve":
return _get_resolver_field_name(f_args, f_kwargs)
return span_name


def _init_span(span):
# type: (Span) -> None
span.set_tag(SPAN_MEASURED_KEY)

sample_rate = config.graphql.get_analytics_sample_rate()
if sample_rate is not None:
span.set_tag(ANALYTICS_SAMPLE_RATE_KEY, sample_rate)


def _get_source_from_query(f_args, f_kwargs):
# type: (Any, Any) -> str
source = get_argument_value(f_args, f_kwargs, 1, "source") # type: Union[Document, str, Source]
source_str = ""
if isinstance(source, Source):
source_str = source.body
elif isinstance(source, str):
source_str = source
else: # Document
source_str = source.loc.source.body
# remove new lines, tabs and extra whitespace from source_str
return re.sub(r"\s+", " ", source_str).strip()


def _get_source_from_execute(f_args, f_kwargs):
# type: (Any, Any) -> str
if graphql_version < (3, 0):
document = get_argument_value(f_args, f_kwargs, 1, "document_ast")
else:
document = get_argument_value(f_args, f_kwargs, 1, "document")

source_str = document.loc.source.body
return re.sub(r"\s+", " ", source_str).strip()


def _get_resolver_field_name(f_args, f_kwargs):
# type: (Any, Any) -> str
fields_arg = 2
fields_kw = "field_nodes"
if graphql_version < (3, 0):
fields_arg = 3
fields_kw = "field_asts"
fields_def = get_argument_value(f_args, f_kwargs, fields_arg, fields_kw)
# field definitions should never be null/empty. A field must exist before
# a graphql execution context attempts to resolve a query.
# Only the first field is resolved:
# https://github.com/graphql-python/graphql-core/blob/v3.0.0/src/graphql/execution/execute.py#L586-L593
return fields_def[0].name.value


def _set_span_errors(result, span):
# type: (Any, Span) -> None
if isinstance(result, list) and result and isinstance(result[0], GraphQLError):
# graphql.valdidate spans wraps functions which returns a list of GraphQLErrors
errors = result
elif isinstance(result, ExecutionResult) and result.errors:
# graphql.execute and graphql.query wrap an ExecutionResult
# which contains a list of errors
errors = result.errors
else:
# do nothing for wrapped functions which do not return a list of errors
return

error_msgs = "\n".join([stringify(error) for error in errors])
span.set_exc_fields(GraphQLError, error_msgs, "")
9 changes: 6 additions & 3 deletions ddtrace/span.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,17 +429,20 @@ def set_exc_info(self, exc_type, exc_val, exc_tb):
if self._ignored_exceptions and any([issubclass(exc_type, e) for e in self._ignored_exceptions]): # type: ignore[arg-type] # noqa
return

self.error = 1

# get the traceback
buff = StringIO()
traceback.print_exception(exc_type, exc_val, exc_tb, file=buff, limit=20)
tb = buff.getvalue()
exc_msg = stringify(exc_val)
self.set_exc_fields(exc_type, exc_msg, tb)

def set_exc_fields(self, exc_type, exc_msg, tb):
# type: (Any, str, str) -> None
# readable version of type (e.g. exceptions.ZeroDivisionError)
self.error = 1
exc_type_str = "%s.%s" % (exc_type.__module__, exc_type.__name__)

self._meta[ERROR_MSG] = stringify(exc_val)
self._meta[ERROR_MSG] = exc_msg
self._meta[ERROR_TYPE] = exc_type_str
self._meta[ERROR_STACK] = tb

Expand Down
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ contacting support.
+--------------------------------------------------+---------------+----------------+
| :ref:`grpc` | >= 1.12.0 | Yes [5]_ |
+--------------------------------------------------+---------------+----------------+
| :ref:`graphql-core` | >= 2.0.0 | Yes |
+--------------------------------------------------+---------------+----------------+
| :ref:`httplib` | \* | Yes |
+--------------------------------------------------+---------------+----------------+
| :ref:`httpx` | >= 0.14.0 | Yes |
Expand Down
7 changes: 7 additions & 0 deletions docs/integrations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ gevent
.. automodule:: ddtrace.contrib.gevent


.. _graphql:

graphql
^^^^^^
.. automodule:: ddtrace.contrib.graphql


.. _grpc:

Grpc
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fastapi
formatter
gRPC
gevent
graphql
greenlet
greenlets
grpc
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
features:
- |
Add tracing support for ``graphql-core``. Version 2.0+ is fully
supported. By transitive dependencies graphene>2.0 is also supported.
20 changes: 20 additions & 0 deletions riotfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -1389,6 +1389,26 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION):
),
],
),
Venv(
name="graphql",
command="pytest {cmdargs} tests/contrib/graphql",
venvs=[
Venv(
pys=select_pys(min_version="3.6", max_version="3.9"),
pkgs={
# graphql-core<=2.2 is not supported in python 3.10
"graphql-core": ["~=2.0.0", "~=2.1.0"],
},
),
Venv(
pys=select_pys(min_version="3.6"),
pkgs={
"graphql-core": ["~=2.2.0", "~=2.3.0", "~=3.0.0", "~=3.1.0", "~=3.2.0", latest],
"pytest-asyncio": latest,
},
),
],
),
Venv(
name="rq",
command="pytest tests/contrib/rq",
Expand Down
Empty file.
Loading