Skip to content

Commit 7a95e9b

Browse files
authored
ci(debugging): add exploration testing (#3991)
This change adds the so-called exploration tests which we use to ensure that the dynamic instrumentation does not affect the expected behaviour of the instrumented code. We select a few popular Python frameworks and inject probes on each single line and wrap every single function. We then run the full test suite for the actual validation. At the end of the test run, line coverage and function call counts are reported as a "proof-of-work"
1 parent 7dc7492 commit 7a95e9b

File tree

7 files changed

+546
-14
lines changed

7 files changed

+546
-14
lines changed

.github/workflows/test_frameworks.yml

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,17 @@ jobs:
4848
run: pip install ../ddtrace
4949
# Allows tests to continue through deprecation warnings for jinja2 and mako
5050
- name: Run tests
51-
# Disable TestServerAdapter_gunicorn.test_simple because it checks for
52-
# log output and it contains the profiler failing to upload
53-
run: ddtrace-run pytest test --continue-on-collection-errors -v -k 'not TestServerAdapter_gunicorn'
51+
# Disable all test_simple tests because they check for
52+
# log output and it contains phony error messages.
53+
run: PYTHONPATH=../ddtrace/tests/debugging/exploration/ ddtrace-run pytest test --continue-on-collection-errors -v -k 'not test_simple'
5454

5555
django-testsuite-3_1:
5656
runs-on: ubuntu-latest
5757
env:
5858
DD_PROFILING_ENABLED: true
5959
DD_TESTING_RAISE: true
60+
DD_DEBUGGER_EXPL_ENCODE: 0 # Disabled to speed up
61+
PYTHONPATH: ../ddtrace/tests/debugging/exploration/:.
6062
defaults:
6163
run:
6264
working-directory: django
@@ -81,15 +83,21 @@ jobs:
8183
- name: Install ddtrace
8284
run: pip install ../ddtrace
8385
- name: Install django
84-
run: pip install ../django
85-
- name: Set Pythonpath
86-
run: echo "PYTHONPATH=." >> $GITHUB_ENV
86+
run: pip install -e .
8787
- name: Disable unsupported tests
8888
run: |
8989
# Note: test_supports_json_field_operational_error will fail with the tracer
9090
# DEV: Insert @skipUnless before the test definition
9191
# DEV: We need to escape the space indenting
9292
sed -i'' '/def test_supports_json_field_operational_error/i \ \ \ \ @skipUnless(False, "test not supported by dd-trace-py")' tests/backends/sqlite/test_features.py
93+
sed -i'' 's/if not filename.startswith(os.path.dirname(django.__file__))/if False/' django/conf/__init__.py
94+
sed -i'' 's/test_paginating_unordered_queryset_raises_warning/paginating_unordered_queryset_raises_warning/' tests/pagination/tests.py
95+
sed -i'' 's/test_access_warning/access_warning/' tests/auth_tests/test_password_reset_timeout_days.py
96+
sed -i'' 's/test_get_or_set_version/get_or_set_version/' tests/cache/tests.py
97+
sed -i'' 's/test_avoid_infinite_loop_on_too_many_subqueries/avoid_infinite_loop_on_too_many_subqueries/' tests/queries/tests.py
98+
sed -i'' 's/test_multivalue_dict_key_error/multivalue_dict_key_error/' tests/view_tests/tests/test_debug.py # Sensitive data leak
99+
sed -i'' 's/test_db_table/db_table/' tests/schema/tests.py
100+
93101
- name: Run tests
94102
# django.tests.requests module interferes with requests library patching in the tracer -> disable requests patch
95103
run: DD_TRACE_REQUESTS_ENABLED=0 ddtrace-run tests/runtests.py
@@ -99,6 +107,7 @@ jobs:
99107
env:
100108
DD_PROFILING_ENABLED: true
101109
DD_TESTING_RAISE: true
110+
PYTHONPATH: ../ddtrace/tests/debugging/exploration/:.
102111
defaults:
103112
run:
104113
working-directory: graphene
@@ -124,8 +133,6 @@ jobs:
124133
run: pip install "pytest-asyncio>0.17,<2"
125134
- name: Install ddtrace
126135
run: pip install ../ddtrace
127-
- name: Set Pythonpath
128-
run: echo "PYTHONPATH=." >> $GITHUB_ENV
129136
- name: Run tests
130137
run: ddtrace-run pytest graphene
131138

@@ -165,14 +172,15 @@ jobs:
165172
- name: Inject ddtrace
166173
run: pip install ../ddtrace
167174
- name: Test
168-
run: ddtrace-run pytest -p no:warnings tests
175+
run: PYTHONPATH=../ddtrace/tests/debugging/exploration/ ddtrace-run pytest -p no:warnings tests
169176

170177
flask-testsuite-1_1_4:
171178
runs-on: ubuntu-latest
172179
env:
173180
TOX_TESTENV_PASSENV: DD_TESTING_RAISE DD_PROFILING_ENABLED
174181
DD_TESTING_RAISE: true
175182
DD_PROFILING_ENABLED: true
183+
PYTHONPATH: ../ddtrace/tests/debugging/exploration/
176184
defaults:
177185
run:
178186
working-directory: flask
@@ -230,6 +238,8 @@ jobs:
230238
env:
231239
# Disabled distributed tracing since there are a lot of tests that assert on headers
232240
DD_HTTPX_DISTRIBUTED_TRACING: "false"
241+
# Debugger exploration testing does not work in CI
242+
# PYTHONPATH: ../ddtrace/tests/debugging/exploration/
233243
# test_pool_timeout raises RuntimeError: The connection pool was closed while 1 HTTP requests/responses were still in-flight
234244
run: pytest -k 'not test_pool_timeout'
235245

@@ -239,6 +249,7 @@ jobs:
239249
TOX_TESTENV_PASSENV: DD_TESTING_RAISE DD_PROFILING_ENABLED
240250
DD_TESTING_RAISE: true
241251
DD_PROFILING_ENABLED: true
252+
PYTHONPATH: ../ddtrace/tests/debugging/exploration/
242253
defaults:
243254
run:
244255
working-directory: mako
@@ -260,18 +271,21 @@ jobs:
260271
run: sed -i 's/pygments/pygments~=2.11.0/' tox.ini
261272
- name: Create tox env
262273
run: tox -e py --notest
263-
- name: Inject ddtrace
264-
run: .tox/py/bin/pip install ../ddtrace
265274
- name: Add pytest configuration for ddtrace
266275
run: echo -e "[pytest]\nddtrace-patch-all = 1" > pytest.ini
267276
- name: Run tests
268-
run: tox -e py
277+
run: |
278+
source .tox/py/bin/activate
279+
pip install ../ddtrace
280+
pip install -e .
281+
pytest -p no:warnings
269282
270283
starlette-testsuite-0_17_1:
271284
runs-on: "ubuntu-latest"
272285
env:
273286
DD_TESTING_RAISE: true
274287
DD_PROFILING_ENABLED: true
288+
PYTHONPATH: ../ddtrace/tests/debugging/exploration/
275289
defaults:
276290
run:
277291
working-directory: starlette
@@ -294,7 +308,7 @@ jobs:
294308
#Parameters for keyword expression skip 3 failing tests that are expected due to asserting on headers. The errors are because our context propagation headers are being added
295309
#test_staticfiles_with_invalid_dir_permissions_returns_401 fails with and without ddtrace enabled
296310
- name: Run tests
297-
run: pytest --ddtrace-patch-all tests -k 'not test_request_headers and not test_subdomain_route and not test_websocket_headers and not test_staticfiles_with_invalid_dir_permissions_returns_401'
311+
run: pytest -W ignore --ddtrace-patch-all tests -k 'not test_request_headers and not test_subdomain_route and not test_websocket_headers and not test_staticfiles_with_invalid_dir_permissions_returns_401'
298312

299313
requests-testsuite-2_26_0:
300314
runs-on: "ubuntu-latest"
@@ -323,7 +337,7 @@ jobs:
323337
- name: MarkupSafe fix
324338
run: pip install --upgrade MarkupSafe==2.0.1
325339
- name: Run tests
326-
run: ddtrace-run pytest -p no:warnings tests
340+
run: PYTHONPATH=../ddtrace/tests/debugging/exploration/ ddtrace-run pytest -p no:warnings tests
327341

328342
asyncpg-testsuite-0_25_0:
329343
# https://github.com/MagicStack/asyncpg/blob/v0.25.0/.github/workflows/tests.yml#L125
@@ -356,3 +370,37 @@ jobs:
356370
python -m pip install -e .[test]
357371
- name: Run tests
358372
run: ddtrace-run python setup.py test
373+
374+
pylons-testsuite-1_0_3:
375+
name: Pylons 1.0.3
376+
runs-on: "ubuntu-latest"
377+
env:
378+
DD_TESTING_RAISE: true
379+
PYTHONPATH: ../ddtrace/tests/debugging/exploration/
380+
defaults:
381+
run:
382+
working-directory: pylons
383+
steps:
384+
- uses: actions/setup-python@v2
385+
with:
386+
python-version: '2.7'
387+
- uses: actions/checkout@v2
388+
with:
389+
path: ddtrace
390+
- uses: actions/checkout@v2
391+
with:
392+
repository: pylons/pylons
393+
ref: master
394+
path: pylons
395+
- name: Install ddtrace
396+
run: pip install ../ddtrace
397+
- name: Install test dependencies
398+
run: pip install -e .[test]
399+
- name: MarkupSafe fix
400+
run: pip install --upgrade MarkupSafe==0.18 pip setuptools --force
401+
- name: Disable failing tests
402+
run: |
403+
sed -i'' "s/test_detect_lang/detect_lang/g" tests/test_units/test_basic_app.py
404+
sed -i'' "s/test_langs/langs/g" tests/test_units/test_basic_app.py
405+
- name: Run tests
406+
run: nosetests
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""
2+
The exploration debugger is a special case of the debugger that is used to
3+
instrument all the lines and functions within a codebase during the test runs.
4+
This is to ensure that the tests still pass with the instrumentation on.
5+
6+
To run an exploration test, set the environment variable
7+
8+
PYTHONPATH=/path/to/tests/debugging/exploration/
9+
10+
Line and function instrumentation can be turned off independently by setting
11+
the environment variables ``DD_DEBUGGER_EXPL_COVERAGE_ENABLED`` and
12+
``DD_DEBUGGER_EXPL_PROFILER_ENABLED`` to ``0``. As a proof of work for the
13+
instrumentation, we use line probes to measure line coverage during tests.
14+
For function probes, we count the number of times a function is called, like
15+
a deterministic profiler. Aa the end of the test runs, we report the function
16+
calls and the line coverage.
17+
18+
Encoding can be turned off for very large test suites by setting the environment
19+
variable ``DD_DEBUGGER_EXPL_ENCODE`` to ``0``.
20+
"""
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from collections import defaultdict
2+
import os
3+
import sys
4+
from types import ModuleType
5+
import typing as t
6+
7+
from debugger import COLS
8+
from debugger import CWD
9+
from debugger import ExplorationDebugger
10+
from debugger import ModuleCollector
11+
from debugger import status
12+
13+
from ddtrace.debugging._function.discovery import FunctionDiscovery
14+
from ddtrace.debugging._probe.model import LineProbe
15+
from ddtrace.debugging._snapshot.model import Snapshot
16+
from ddtrace.internal.module import origin
17+
from ddtrace.internal.utils.formats import asbool
18+
19+
20+
# Track all the covered modules and its lines. Indexed by module origin.
21+
_tracked_modules = {} # type: t.Dict[str, t.Tuple[ModuleType, t.Set[int]]]
22+
23+
ENABLED = asbool(os.getenv("DD_DEBUGGER_EXPL_COVERAGE_ENABLED", True))
24+
DELETE_LINE_PROBE = asbool(os.getenv("DD_DEBUGGER_EXPL_DELETE_LINE_PROBE", False))
25+
26+
27+
class LineCollector(ModuleCollector):
28+
def on_collect(self, discovery):
29+
# type: (FunctionDiscovery) -> None
30+
o = origin(discovery._module)
31+
status("[coverage] collecting lines from %s" % o)
32+
_tracked_modules[o] = (discovery._module, {_ for _ in discovery.keys()})
33+
LineCoverage.add_probes(
34+
[
35+
LineProbe(
36+
probe_id="@".join([str(hash(f)), str(line)]),
37+
source_file=origin(sys.modules[f.__module__]),
38+
line=line,
39+
rate=0.0,
40+
)
41+
for line, functions in discovery.items()
42+
for f in functions
43+
]
44+
)
45+
46+
47+
class LineCoverage(ExplorationDebugger):
48+
__watchdog__ = LineCollector
49+
50+
@classmethod
51+
def report_coverage(cls):
52+
# type: () -> None
53+
seen_lines_map = defaultdict(set)
54+
for probe in (_ for _ in cls.get_triggered_probes() if isinstance(_, LineProbe)):
55+
seen_lines_map[probe.source_file].add(probe.line)
56+
57+
try:
58+
w = max(len(os.path.relpath(o, CWD)) for o in _tracked_modules)
59+
except ValueError:
60+
w = int(COLS * 0.75)
61+
print(("{:=^%ds}" % COLS).format(" Line coverage "))
62+
print("")
63+
head = ("{:<%d} {:>5} {:>6}" % w).format("Source", "Lines", "Covered")
64+
print(head)
65+
print("=" * len(head))
66+
67+
total_lines = 0
68+
total_covered = 0
69+
for o, (_, lines) in sorted(_tracked_modules.items(), key=lambda x: x[0]):
70+
total_lines += len(lines)
71+
seen_lines = seen_lines_map[o]
72+
total_covered += len(seen_lines)
73+
print(
74+
("{:<%d} {:>5} {: 6.0f}%%" % w).format(
75+
os.path.relpath(o, CWD),
76+
len(lines),
77+
len(seen_lines) * 100.0 / len(lines) if lines else 0,
78+
)
79+
)
80+
if not total_lines:
81+
print("No lines found")
82+
return
83+
print("-" * len(head))
84+
print(("{:<%d} {:>5} {: 6.0f}%%" % w).format("TOTAL", total_lines, total_covered * 100.0 / total_lines))
85+
print("")
86+
87+
@classmethod
88+
def on_disable(cls):
89+
# type: () -> None
90+
cls.report_coverage()
91+
92+
@classmethod
93+
def on_snapshot(cls, snapshot):
94+
# type: (Snapshot) -> None
95+
if DELETE_LINE_PROBE:
96+
cls.delete_probe(snapshot.probe)
97+
98+
99+
if ENABLED:
100+
LineCoverage.enable()
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import os
2+
import typing as t
3+
4+
from debugger import COLS
5+
from debugger import ExplorationDebugger
6+
from debugger import ModuleCollector
7+
from debugger import status
8+
9+
from ddtrace.debugging._function.discovery import FunctionDiscovery
10+
from ddtrace.debugging._probe.model import FunctionProbe
11+
from ddtrace.internal.utils.formats import asbool
12+
13+
14+
# Track all instrumented functions and their call count.
15+
_tracked_funcs = {} # type: t.Dict[str, int]
16+
17+
18+
ENABLED = asbool(os.getenv("DD_DEBUGGER_EXPL_PROFILER_ENABLED", True))
19+
20+
21+
class FunctionCollector(ModuleCollector):
22+
def on_collect(self, discovery):
23+
# type: (FunctionDiscovery) -> None
24+
module = discovery._module
25+
status("[profiler] Collecting functions from %s" % module.__name__)
26+
for fname, f in discovery._fullname_index.items():
27+
_tracked_funcs[fname] = 0
28+
DeterministicProfiler.add_probe(
29+
FunctionProbe(
30+
probe_id=str(hash(f)),
31+
module=module.__name__,
32+
func_qname=fname.replace(module.__name__, "").lstrip("."),
33+
rate=float("inf"),
34+
)
35+
)
36+
37+
38+
class DeterministicProfiler(ExplorationDebugger):
39+
__watchdog__ = FunctionCollector
40+
41+
@classmethod
42+
def report_func_calls(cls):
43+
# type: () -> None
44+
for probe in (_ for _ in cls.get_triggered_probes() if isinstance(_, FunctionProbe)):
45+
_tracked_funcs[".".join([probe.module, probe.func_qname])] += 1
46+
print(("{:=^%ds}" % COLS).format(" Function coverage "))
47+
print("")
48+
calls = sorted([(v, k) for k, v in _tracked_funcs.items()], reverse=True)
49+
if not calls:
50+
print("No functions called")
51+
return
52+
w = max(len(f) for _, f in calls)
53+
called = sum(v > 0 for v in _tracked_funcs.values())
54+
print("Functions called: %d/%d" % (called, len(_tracked_funcs)))
55+
print("")
56+
print(("{:<%d} {:>5}" % w).format("Function", "Calls"))
57+
print("=" * (w + 6))
58+
for calls, func in calls:
59+
print(("{:<%d} {:>5}" % w).format(func, calls))
60+
print("")
61+
62+
@classmethod
63+
def on_disable(cls):
64+
# type: () -> None
65+
cls.report_func_calls()
66+
67+
68+
if ENABLED:
69+
DeterministicProfiler.enable()

0 commit comments

Comments
 (0)