Skip to content

Commit 66b96e9

Browse files
chore(ci_visibility): introduce experimental internal coverage collector (#8727)
Introduces the `ModuleCodeCollector` which collects coverage and executable lines for imported modules. The collector has two modes, one that stores executed lines on the singleton instance, and one that uses context variables and a context manager. This also introduces changes to the `pytest` integration as well as the `CIVisibility` service's use of coverage to feature-flag using the new module collector. The features are gated behind `_DD_USE_INTERNAL_COVERAGE` and `_DD_COVER_SESSION` (which introduces a new `coverage run` like behavior). There are no unit tests though the overall use of the feature flags has been tested quite extensively in the process of collecting performance data. There are no release notes since this is an entirely undocumented feature for the moment. ## Checklist - [x] Change(s) are motivated and described in the PR description - [x] Testing strategy is described if automated tests are not included in the PR - [x] Risks are described (performance impact, potential for breakage, maintainability) - [x] Change is maintainable (easy to change, telemetry, documentation) - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) are followed or label `changelog/no-changelog` is set - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)) - [x] Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) - [x] If this PR changes the public interface, I've notified `@DataDog/apm-tees`. - [x] If change touches code that signs or publishes builds or packages, or handles credentials of any kind, I've requested a review from `@DataDog/security-design-and-guidance`. ## Reviewer Checklist - [x] Title is accurate - [x] All changes are related to the pull request's stated goal - [x] Description motivates each change - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - [x] Testing strategy adequately addresses listed risks - [x] Change is maintainable (easy to change, telemetry, documentation) - [x] Release note makes sense to a user of the library - [x] Author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - [x] Backport labels are set in a manner that is consistent with the [release branch maintenance policy](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting) --------- Co-authored-by: Gabriele N. Tornetta <gabriele.tornetta@datadoghq.com> Co-authored-by: Gabriele N. Tornetta <P403n1x87@users.noreply.github.com>
1 parent f53484d commit 66b96e9

File tree

14 files changed

+716
-4
lines changed

14 files changed

+716
-4
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ ddtrace/ext/ci_visibility @DataDog/ci-app-libraries
4040
ddtrace/ext/test.py @DataDog/ci-app-libraries
4141
ddtrace/internal/ci_visibility @DataDog/ci-app-libraries
4242
ddtrace/internal/codeowners.py @DataDog/apm-core-python @datadog/ci-app-libraries
43+
ddtrace/internal/coverage @DataDog/apm-core-python @datadog/ci-app-libraries @Datadog/debugger-python
4344
tests/internal/test_codeowners.py @datadog/ci-app-libraries
4445
tests/ci_visibility @DataDog/ci-app-libraries
4546
tests/tracer/test_ci.py @DataDog/ci-app-libraries

ddtrace/contrib/pytest/_plugin_v1.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"""
1414
from doctest import DocTest
1515
import json
16+
import os
1617
import re
1718
from typing import Dict # noqa:F401
1819

@@ -46,6 +47,7 @@
4647
from ddtrace.internal.ci_visibility.constants import SUITE_ID as _SUITE_ID
4748
from ddtrace.internal.ci_visibility.constants import SUITE_TYPE as _SUITE_TYPE
4849
from ddtrace.internal.ci_visibility.constants import TEST
50+
from ddtrace.internal.ci_visibility.coverage import USE_DD_COVERAGE
4951
from ddtrace.internal.ci_visibility.coverage import _module_has_dd_coverage_enabled
5052
from ddtrace.internal.ci_visibility.coverage import _report_coverage_to_span
5153
from ddtrace.internal.ci_visibility.coverage import _start_coverage
@@ -59,13 +61,19 @@
5961
from ddtrace.internal.ci_visibility.utils import get_relative_or_absolute_path_for_path
6062
from ddtrace.internal.ci_visibility.utils import take_over_logger_stream_handler
6163
from ddtrace.internal.constants import COMPONENT
64+
from ddtrace.internal.coverage.code import ModuleCodeCollector
6265
from ddtrace.internal.logger import get_logger
66+
from ddtrace.internal.utils.formats import asbool
6367

6468

6569
log = get_logger(__name__)
6670

6771
_global_skipped_elements = 0
6872

73+
# COVER_SESSION is an experimental feature flag that provides full coverage (similar to coverage run), and is an
74+
# experimental feature. It currently significantly increases test import time and should not be used.
75+
COVER_SESSION = asbool(os.environ.get("_DD_COVER_SESSION", "false"))
76+
6977

7078
def _is_pytest_8_or_later():
7179
if hasattr(pytest, "version_tuple"):
@@ -859,3 +867,14 @@ def pytest_ddtrace_get_item_test_name(item):
859867
if item.config.getoption("ddtrace-include-class-name") or item.config.getini("ddtrace-include-class-name"):
860868
return "%s.%s" % (item.cls.__name__, item.name)
861869
return item.name
870+
871+
@staticmethod
872+
@pytest.hookimpl(trylast=True)
873+
def pytest_terminal_summary(terminalreporter, exitstatus, config):
874+
# Reports coverage if experimental session-level coverage is enabled.
875+
if USE_DD_COVERAGE and COVER_SESSION:
876+
ModuleCodeCollector.report()
877+
try:
878+
ModuleCodeCollector.write_json_report_to_file("dd_coverage.json")
879+
except Exception:
880+
log.debug("Failed to write coverage report to file", exc_info=True)

ddtrace/contrib/pytest/plugin.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
expected failures.
1212
1313
"""
14+
import os
1415
from typing import Dict # noqa:F401
1516

1617
import pytest
@@ -22,6 +23,21 @@
2223
PATCH_ALL_HELP_MSG = "Call ddtrace.patch_all before running tests."
2324

2425

26+
def _is_enabled_early(early_config):
27+
"""Hackily checks if the ddtrace plugin is enabled before the config is fully populated.
28+
29+
This is necessary because the module watchdog for coverage collectio needs to be enabled as early as possible.
30+
"""
31+
if (
32+
"--no-ddtrace" in early_config.invocation_params.args
33+
or early_config.getini("ddtrace") is False
34+
or early_config.getini("no-ddtrace")
35+
):
36+
return False
37+
38+
return "--ddtrace" in early_config.invocation_params.args or early_config.getini("ddtrace")
39+
40+
2541
def is_enabled(config):
2642
"""Check if the ddtrace plugin is enabled."""
2743
return (config.getoption("ddtrace") or config.getini("ddtrace")) and not config.getoption("no-ddtrace")
@@ -69,6 +85,31 @@ def pytest_addoption(parser):
6985
parser.addini("ddtrace-include-class-name", DDTRACE_INCLUDE_CLASS_HELP_MSG, type="bool")
7086

7187

88+
def pytest_load_initial_conftests(early_config, parser, args):
89+
if _is_enabled_early(early_config):
90+
# Enables experimental use of ModuleCodeCollector for coverage collection.
91+
from ddtrace.internal.ci_visibility.coverage import USE_DD_COVERAGE
92+
from ddtrace.internal.logger import get_logger
93+
from ddtrace.internal.utils.formats import asbool
94+
95+
log = get_logger(__name__)
96+
97+
COVER_SESSION = asbool(os.environ.get("_DD_COVER_SESSION", "false"))
98+
99+
if USE_DD_COVERAGE:
100+
from ddtrace.internal.coverage.code import ModuleCodeCollector
101+
102+
if not ModuleCodeCollector.is_installed():
103+
ModuleCodeCollector.install()
104+
if COVER_SESSION:
105+
ModuleCodeCollector.start_coverage()
106+
else:
107+
if COVER_SESSION:
108+
log.warning(
109+
"_DD_COVER_SESSION must be used with _DD_USE_INTERNAL_COVERAGE but not DD_CIVISIBILITY_ITR_ENABLED"
110+
)
111+
112+
72113
def pytest_configure(config):
73114
config.addinivalue_line("markers", "dd_tags(**kwargs): add tags to current span")
74115
if is_enabled(config):

ddtrace/internal/ci_visibility/coverage.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from itertools import groupby
22
import json
3+
import os
34
from typing import Dict # noqa:F401
45
from typing import Iterable # noqa:F401
56
from typing import List # noqa:F401
67
from typing import Optional # noqa:F401
78
from typing import Tuple # noqa:F401
9+
from typing import Union # noqa:F401
810

911
import ddtrace
1012
from ddtrace.internal.ci_visibility.constants import COVERAGE_TAG_NAME
@@ -16,12 +18,18 @@
1618
from ddtrace.internal.ci_visibility.telemetry.coverage import record_code_coverage_finished
1719
from ddtrace.internal.ci_visibility.telemetry.coverage import record_code_coverage_started
1820
from ddtrace.internal.ci_visibility.utils import get_relative_or_absolute_path_for_path
21+
from ddtrace.internal.coverage.code import ModuleCodeCollector
1922
from ddtrace.internal.logger import get_logger
23+
from ddtrace.internal.utils.formats import asbool
2024

2125

2226
log = get_logger(__name__)
2327
_global_relative_file_paths_for_cov: Dict[str, Dict[str, str]] = {}
2428

29+
# This feature-flags experimental collection of code coverage via our internal ModuleCodeCollector.
30+
# It is disabled by default because it is not production-ready.
31+
USE_DD_COVERAGE = asbool(os.environ.get("_DD_USE_INTERNAL_COVERAGE", "false"))
32+
2533
try:
2634
from coverage import Coverage
2735
from coverage import version_info as coverage_version
@@ -52,19 +60,30 @@ def _initialize_coverage(root_dir):
5260

5361

5462
def _start_coverage(root_dir: str):
63+
# Experimental feature to use internal coverage collection
64+
if USE_DD_COVERAGE:
65+
ctx = ModuleCodeCollector.CollectInContext()
66+
return ctx
5567
coverage = _initialize_coverage(root_dir)
5668
coverage.start()
5769
return coverage
5870

5971

6072
def _stop_coverage(module):
73+
# Experimental feature to use internal coverage collection
74+
if USE_DD_COVERAGE:
75+
module._dd_coverage.__exit__()
76+
return
6177
if _module_has_dd_coverage_enabled(module):
6278
module._dd_coverage.stop()
6379
module._dd_coverage.erase()
6480
del module._dd_coverage
6581

6682

6783
def _module_has_dd_coverage_enabled(module, silent_mode: bool = False) -> bool:
84+
# Experimental feature to use internal coverage collection
85+
if USE_DD_COVERAGE:
86+
return hasattr(module, "_dd_coverage")
6887
if not hasattr(module, "_dd_coverage"):
6988
if not silent_mode:
7089
log.warning("Datadog Coverage has not been initiated")
@@ -84,6 +103,13 @@ def _switch_coverage_context(
84103
coverage_data: Coverage, unique_test_name: str, framework: Optional[TEST_FRAMEWORKS] = None
85104
):
86105
record_code_coverage_started(COVERAGE_LIBRARY.COVERAGEPY, framework)
106+
# Experimental feature to use internal coverage collection
107+
if isinstance(coverage_data, ModuleCodeCollector.CollectInContext):
108+
if USE_DD_COVERAGE:
109+
# In this case, coverage_data is the context manager supplied by ModuleCodeCollector.CollectInContext
110+
coverage_data.__enter__()
111+
return
112+
87113
if not _coverage_has_valid_data(coverage_data, silent_mode=True):
88114
return
89115
coverage_data._collector.data.clear() # type: ignore[union-attr]
@@ -97,6 +123,22 @@ def _switch_coverage_context(
97123
def _report_coverage_to_span(
98124
coverage_data: Coverage, span: ddtrace.Span, root_dir: str, framework: Optional[TEST_FRAMEWORKS] = None
99125
):
126+
# Experimental feature to use internal coverage collection
127+
if isinstance(coverage_data, ModuleCodeCollector.CollectInContext):
128+
if USE_DD_COVERAGE:
129+
# In this case, coverage_data is the context manager supplied by ModuleCodeCollector.CollectInContext
130+
files = ModuleCodeCollector.report_seen_lines()
131+
if not files:
132+
return
133+
span.set_tag_str(
134+
COVERAGE_TAG_NAME,
135+
json.dumps({"files": files}),
136+
)
137+
record_code_coverage_finished(COVERAGE_LIBRARY.COVERAGEPY, framework)
138+
coverage_data.__exit__(None, None, None)
139+
140+
return
141+
100142
span_id = str(span.trace_id)
101143
if not _coverage_has_valid_data(coverage_data):
102144
record_code_coverage_error()

ddtrace/internal/coverage/__init__.py

Whitespace-only changes.

ddtrace/internal/coverage/_native.c

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#define PY_SSIZE_T_CLEAN
2+
#include <Python.h>
3+
4+
#if PY_VERSION_HEX < 0x030c0000
5+
#if defined __GNUC__ && defined HAVE_STD_ATOMIC
6+
#undef HAVE_STD_ATOMIC
7+
#endif
8+
#endif
9+
10+
// ----------------------------------------------------------------------------
11+
static PyObject*
12+
replace_in_tuple(PyObject* m, PyObject* args)
13+
{
14+
PyObject* tuple = NULL;
15+
PyObject* item = NULL;
16+
PyObject* replacement = NULL;
17+
18+
if (!PyArg_ParseTuple(args, "O!OO", &PyTuple_Type, &tuple, &item, &replacement))
19+
return NULL;
20+
21+
for (Py_ssize_t i = 0; i < PyTuple_Size(tuple); i++) {
22+
PyObject* current = PyTuple_GetItem(tuple, i);
23+
if (current == item) {
24+
Py_DECREF(current);
25+
// !!! DANGER !!!
26+
PyTuple_SET_ITEM(tuple, i, replacement);
27+
Py_INCREF(replacement);
28+
}
29+
}
30+
31+
Py_RETURN_NONE;
32+
}
33+
34+
// ----------------------------------------------------------------------------
35+
static PyMethodDef native_methods[] = {
36+
{ "replace_in_tuple", replace_in_tuple, METH_VARARGS, "Replace an item in a tuple." },
37+
{ NULL, NULL, 0, NULL } /* Sentinel */
38+
};
39+
40+
// ----------------------------------------------------------------------------
41+
static struct PyModuleDef nativemodule = {
42+
PyModuleDef_HEAD_INIT,
43+
"_native", /* name of module */
44+
NULL, /* module documentation, may be NULL */
45+
-1, /* size of per-interpreter state of the module,
46+
or -1 if the module keeps state in global variables. */
47+
native_methods,
48+
};
49+
50+
// ----------------------------------------------------------------------------
51+
PyMODINIT_FUNC
52+
PyInit__native(void)
53+
{
54+
PyObject* m;
55+
56+
m = PyModule_Create(&nativemodule);
57+
if (m == NULL)
58+
return NULL;
59+
60+
return m;
61+
}

ddtrace/internal/coverage/_native.pyi

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import typing as t
2+
3+
def replace_in_tuple(tup: tuple, item: t.Any, replacement: t.Any) -> None: ...

0 commit comments

Comments
 (0)