Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(gunicorn): add tests #4776

Merged
merged 12 commits into from
Dec 20, 2022
9 changes: 9 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,13 @@ jobs:
pattern: "grpc"
snapshot: true

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

httplib:
<<: *machine_executor
steps:
Expand Down Expand Up @@ -1170,6 +1177,7 @@ requires_tests: &requires_tests
- gevent
- graphql
- grpc
- gunicorn
- httplib
- httpx
- integration_agent
Expand Down Expand Up @@ -1269,6 +1277,7 @@ workflows:
- graphene: *requires_base_venvs
- graphql: *requires_base_venvs
- grpc: *requires_base_venvs
- gunicorn: *requires_base_venvs
- httplib: *requires_base_venvs
- httpx: *requires_base_venvs
- integration_agent: *requires_base_venvs
Expand Down
1 change: 1 addition & 0 deletions .circleci/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"graphene": "3.1.1",
"graphql-core": "3.2.3",
"grpcio": "1.51.1",
"gunicorn": "20.1.0",
"httpretty": "1.1.4",
"httpx": "0.23.1",
"hypothesis": "6.60.0",
Expand Down
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def pytest_configure(config):
# DEV: We can only ignore folders/modules, we cannot ignore individual files
# DEV: We must wrap with `@pytest.mark.hookwrapper` to inherit from default (e.g. honor `--ignore`)
# https://github.com/pytest-dev/pytest/issues/846#issuecomment-122129189
@pytest.mark.hookwrapper
@pytest.hookimpl(hookwrapper=True)
def pytest_ignore_collect(path, config):
"""
Skip directories defining a required minimum Python version
Expand Down
23 changes: 23 additions & 0 deletions ddtrace/contrib/gunicorn/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
``ddtrace`` supports `Gunicorn <https://gunicorn.org>`__.

If the application is using the ``gevent`` worker class, ``gevent`` monkey patching must be performed before loading the
``ddtrace`` library.

There are different options to ensure this happens:

- If using ``ddtrace-run``, set the environment variable ``DD_GEVENT_PATCH_ALL=1``.

- Replace ``ddtrace-run`` by using ``import ddtrace.bootstrap.sitecustomize`` as the first import of the application.

- Use a `post_worker_init <https://docs.gunicorn.org/en/stable/settings.html#post-worker-init>`_
hook to import ``ddtrace.bootstrap.sitecustomize``.
P403n1x87 marked this conversation as resolved.
Show resolved Hide resolved
"""


def patch():
pass


def unpatch():
pass
22 changes: 0 additions & 22 deletions docs/advanced_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -677,25 +677,3 @@ Example with uWSGI ini file:
.. code-block:: bash

uwsgi --ini uwsgi.ini


.. _gunicorn:

Gunicorn
--------

``ddtrace`` supports `Gunicorn <https://gunicorn.org>`__.

However, if you are using the ``gevent`` worker class, you have to make sure
``gevent`` monkey patching is done before loading the ``ddtrace`` library.

There are different options to make that happen:

- If you rely on ``ddtrace-run``, you must set ``DD_GEVENT_PATCH_ALL=1`` in
your environment to have gevent patched first-thing.

- Replace ``ddtrace-run`` by using ``import ddtrace.bootstrap.sitecustomize``
as the first import of your application.

- Use a `post_worker_init <https://docs.gunicorn.org/en/stable/settings.html#post-worker-init>`_
hook to import ``ddtrace.bootstrap.sitecustomize``.
2 changes: 2 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ contacting support.
+--------------------------------------------------+---------------+----------------+
| :ref:`graphql-core <graphql>` | >= 2.0.0 | Yes |
+--------------------------------------------------+---------------+----------------+
| :ref:`gunicorn <gunicorn>` | >= 19.10.0 | No |
+--------------------------------------------------+---------------+----------------+
| :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 @@ -203,6 +203,13 @@ Grpc
.. automodule:: ddtrace.contrib.grpc


.. _gunicorn:

gunicorn
^^^^^^^^
.. automodule:: ddtrace.contrib.gunicorn


.. _httplib:

httplib
Expand Down
3 changes: 2 additions & 1 deletion docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ graphene
greenlet
greenlets
grpc
gunicorn
hostname
http
httplib
Expand Down Expand Up @@ -204,4 +205,4 @@ serverless
cattrs
IAST
DES
Blowfish
Blowfish
16 changes: 16 additions & 0 deletions riotfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -2586,6 +2586,22 @@ def select_pys(min_version=MIN_PYTHON_VERSION, max_version=MAX_PYTHON_VERSION):
"molten": [">=0.6,<0.7", ">=0.7,<0.8", ">=1.0,<1.1", latest],
},
),
Venv(
name="gunicorn",
command="pytest {cmdargs} tests/contrib/gunicorn",
pkgs={"requests": latest},
venvs=[
Venv(
pys="2.7",
# Gunicorn ended Python 2 support after 19.10.0
pkgs={"gunicorn": "==19.10.0"},
),
Venv(
pys=select_pys(min_version="3.5"),
pkgs={"gunicorn": ["==19.10.0", "==20.0.4", latest]},
P403n1x87 marked this conversation as resolved.
Show resolved Hide resolved
),
],
),
],
)

Expand Down
Empty file.
4 changes: 4 additions & 0 deletions tests/contrib/gunicorn/simple_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
def app(_, start_response):
data = b"Hello, World!\n"
start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(data)))])
return iter([data])
130 changes: 130 additions & 0 deletions tests/contrib/gunicorn/test_gunicorn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import os
import subprocess
import sys
from typing import Dict
from typing import NamedTuple

import pytest
import tenacity

from ddtrace.internal.compat import stringify
from tests.webclient import Client


GunicornServerSettings = NamedTuple(
"GunicornServerSettings",
[
("env", Dict[str, str]),
("directory", str),
("app_path", str),
("num_workers", str),
("worker_class", str),
("bind", str),
("use_ddtracerun", bool),
("post_worker_init", str),
],
)


_post_worker_init_ddtrace = """
def post_worker_init(worker):
import ddtrace.bootstrap.sitecustomize
"""


def _gunicorn_settings_factory(
env=os.environ.copy(), # type: Dict[str, str]
directory=os.getcwd(), # type: str
app_path="tests.contrib.gunicorn.simple_app:app", # type: str
num_workers="4", # type: str
worker_class="sync", # type: str
bind="0.0.0.0:8080", # type: str
use_ddtracerun=True, # type: bool
post_worker_init="", # type: str
):
# type: (...) -> GunicornServerSettings
"""Factory for creating gunicorn settings with simple defaults if settings are not defined."""
return GunicornServerSettings(
env=env,
directory=directory,
app_path=app_path,
num_workers=num_workers,
worker_class=worker_class,
bind=bind,
use_ddtracerun=use_ddtracerun,
post_worker_init=post_worker_init,
)


@pytest.fixture
def gunicorn_server_settings():
yield _gunicorn_settings_factory()


@pytest.fixture
def gunicorn_server(gunicorn_server_settings, tmp_path):
cfg_file = tmp_path / "gunicorn.conf.py"
cfg = """
{post_worker_init}

workers = {num_workers}
worker_class = "{worker_class}"
bind = "{bind}"
""".format(
post_worker_init=gunicorn_server_settings.post_worker_init,
bind=gunicorn_server_settings.bind,
num_workers=gunicorn_server_settings.num_workers,
worker_class=gunicorn_server_settings.worker_class,
)
cfg_file.write_text(stringify(cfg))
cmd = []
if gunicorn_server_settings.use_ddtracerun:
cmd = ["ddtrace-run"]
cmd += ["gunicorn", "--config", str(cfg_file), str(gunicorn_server_settings.app_path)]
print("Running %r with configuration file %s" % (" ".join(cmd), cfg))
p = subprocess.Popen(
cmd,
env=gunicorn_server_settings.env,
cwd=gunicorn_server_settings.directory,
stdout=sys.stdout,
stderr=sys.stderr,
close_fds=True,
preexec_fn=os.setsid,
)
try:
client = Client("http://%s" % gunicorn_server_settings.bind)
# Wait for the server to start up
try:
client.wait(max_tries=20, delay=0.5)
P403n1x87 marked this conversation as resolved.
Show resolved Hide resolved
except tenacity.RetryError:
# if proc.returncode is not None:
# process failed
raise TimeoutError("Server failed to start, see stdout and stderr logs")

yield client

try:
client.get_ignored("/shutdown")
except Exception:
pass
finally:
p.terminate()
p.wait()


def test_basic(gunicorn_server):
r = gunicorn_server.get("/")
assert r.status_code == 200
assert r.content == b"Hello, World!\n"


@pytest.mark.snapshot(ignores=["meta.result_class"])
P403n1x87 marked this conversation as resolved.
Show resolved Hide resolved
@pytest.mark.parametrize(
"gunicorn_server_settings", [_gunicorn_settings_factory(app_path="tests.contrib.gunicorn.wsgi_mw_app:app")]
)
def test_traced_basic(gunicorn_server_settings, gunicorn_server):
# meta.result_class is listiterator vs list_iterator in PY2 vs PY3.
# Ignore this field to avoid having to create mostly duplicate snapshots in Python 2 and 3.
r = gunicorn_server.get("/")
assert r.status_code == 200
assert r.content == b"Hello, World!\n"
22 changes: 22 additions & 0 deletions tests/contrib/gunicorn/wsgi_mw_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from ddtrace import tracer
from ddtrace.contrib.wsgi import DDWSGIMiddleware
from tests.webclient import PingFilter


tracer.configure(
settings={
"FILTERS": [PingFilter()],
}
)


def simple_app(environ, start_response):
if environ["RAW_URI"] == "/shutdown":
tracer.shutdown()

data = b"Hello, World!\n"
start_response("200 OK", [("Content-Type", "text/plain"), ("Content-Length", str(len(data)))])
return iter([data])


app = DDWSGIMiddleware(simple_app)
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
[[
{
"name": "wsgi.request",
"service": "wsgi",
"resource": "GET /",
"trace_id": 0,
"span_id": 1,
"parent_id": 0,
"type": "web",
"error": 0,
"meta": {
"_dd.p.dm": "-0",
"http.method": "GET",
"http.status_code": "200",
"http.status_msg": "OK",
"http.url": "http://0.0.0.0:8080/",
"runtime-id": "b73097ce1a54484f872936703dc77f48"
},
"metrics": {
"_dd.agent_psr": 1.0,
"_dd.top_level": 1,
"_dd.tracer_kr": 1.0,
"_sampling_priority_v1": 1,
"process_id": 28765
},
"duration": 198000,
"start": 1670886340849791000
},
{
"name": "wsgi.application",
"service": "wsgi",
"resource": "wsgi.application",
"trace_id": 0,
"span_id": 2,
"parent_id": 1,
"type": "",
"error": 0,
"duration": 49000,
"start": 1670886340849877000
},
{
"name": "wsgi.start_response",
"service": "wsgi",
"resource": "wsgi.start_response",
"trace_id": 0,
"span_id": 4,
"parent_id": 2,
"type": "web",
"error": 0,
"duration": 14000,
"start": 1670886340849902000
},
{
"name": "wsgi.response",
"service": "wsgi",
"resource": "wsgi.response",
"trace_id": 0,
"span_id": 3,
"parent_id": 1,
"type": "",
"error": 0,
"meta": {
"result_class": "list_iterator"
},
"duration": 44000,
"start": 1670886340849936000
}]]