Skip to content

Commit 2aa6d34

Browse files
fix(ci_visibility): sys.monitoring deinstrumentation [backport 3.16] (#15023)
Backport 93aff64 from #14859 to 3.16. ## Description Fixes a coverage tracking performance issue by leveraging de-instrumentation after line events, and re-instrumentation between coverage collection contexts on Python 3.12+. **Problem:** The coverage tracking wasn't using `sys.monitoring` API's `DISABLE` for `LINE` events once the line coverage was tracked. **Solution:** Return `sys.monitoring.DISABLE` once a line is tracked, and call `sys.monitoring.restart_events()` when entering new coverage contexts to re-enable monitoring. ## Testing - New tests covering sequential contexts, nested contexts, dynamic imports, and nested import chains - Tests verify that coverage is complete and consistent across multiple context switches ## Risks Low - only affects Python 3.12+ coverage, uses `sys.monitoring.DISABLE` and `sys.monitoring.restart_events()` [API](https://docs.python.org/3/library/sys.monitoring.html#disabling-events), extensively tested. However, if other tool was using this API at the same time, when we call `sys.monitoring.restart_events()`, we would be re-enabling their disabled events as well. ## Additional Notes ### Performance Gain Example: The best performance gains for this PR happen when recursive code or loops are used heavily in the tested code, for example a recursive implementation of a fibonacci sequence calculator: ``` # fibonacci.py def fibonacci(n): if n <= 1: return n return fibonacci(n - 1) + fibonacci(n - 2) ``` Then running just this test: ``` # test_fibonacci.py from fibonacci import fibonacci def test_fibonacci(): assert fibonacci(35) == 9227465 ``` Yields the following results: No coverage: ``` 1 passed in 0.98s ``` current coverage (main): ``` 1 passed in 24.11s ``` new coverage (this branch): ``` 1 passed in 1.01s ``` Co-authored-by: Federico Mon <[email protected]>
1 parent 4148369 commit 2aa6d34

18 files changed

+1023
-3
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Coverage benchmark configurations for fibonacci code
2+
# Tests sys.monitoring.DISABLE optimization performance
3+
4+
small: &base
5+
fib_n_recursive: 10
6+
7+
medium:
8+
<<: *base
9+
fib_n_recursive: 15
10+
11+
large:
12+
<<: *base
13+
fib_n_recursive: 20
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Benchmark for coverage collection on recursive code.
3+
4+
This benchmark ensures that the sys.monitoring.DISABLE optimization
5+
doesn't regress. The DISABLE return value prevents the handler from being
6+
called repeatedly for the same line in recursive functions and loops.
7+
8+
Without DISABLE: Handler called on every line execution
9+
With DISABLE: Handler called once per unique line
10+
"""
11+
12+
from typing import Callable
13+
from typing import Generator
14+
15+
import bm
16+
17+
18+
class CoverageFibonacci(bm.Scenario):
19+
"""
20+
Benchmark coverage collection performance on recursive and iterative code.
21+
22+
Tests the DISABLE optimization: returning sys.monitoring.DISABLE prevents
23+
the handler from being called repeatedly for the same line.
24+
"""
25+
26+
fib_n_recursive: int
27+
28+
def run(self) -> Generator[Callable[[int], None], None, None]:
29+
import os
30+
from pathlib import Path
31+
32+
from ddtrace.internal.coverage.code import ModuleCodeCollector
33+
from ddtrace.internal.coverage.installer import install
34+
35+
# Install coverage
36+
install(include_paths=[Path(os.getcwd())])
37+
38+
# Import after installation
39+
from utils import fibonacci_recursive
40+
41+
def _(loops: int) -> None:
42+
for _ in range(loops):
43+
# Use coverage context to simulate real pytest per-test coverage
44+
with ModuleCodeCollector.CollectInContext():
45+
# Recursive: Many function calls, same lines executed repeatedly
46+
result = fibonacci_recursive(self.fib_n_recursive)
47+
48+
# Verify correctness (don't optimize away)
49+
assert result > 0
50+
51+
yield _
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/usr/bin/env python
2+
3+
4+
def fibonacci_recursive(n):
5+
if n <= 1:
6+
return n
7+
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)

ddtrace/internal/coverage/code.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from copy import deepcopy
44
from inspect import getmodule
55
import os
6+
import sys
67
from types import CodeType
78
from types import ModuleType
89
import typing as t
@@ -231,6 +232,11 @@ def __enter__(self):
231232
if self.is_import_coverage:
232233
ctx_is_import_coverage.set(self.is_import_coverage)
233234

235+
# For Python 3.12+, re-enable monitoring that was disabled by previous contexts
236+
# This ensures each test/suite gets accurate coverage data
237+
if sys.version_info >= (3, 12):
238+
sys.monitoring.restart_events()
239+
234240
return self
235241

236242
def __exit__(self, *args, **kwargs):

ddtrace/internal/coverage/instrumentation_py3_12.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,26 @@
2121
RETURN_CONST = dis.opmap["RETURN_CONST"]
2222
EMPTY_MODULE_BYTES = bytes([RESUME, 0, RETURN_CONST, 0])
2323

24+
# Store: (hook, path, import_names_by_line)
2425
_CODE_HOOKS: t.Dict[CodeType, t.Tuple[HookType, str, t.Dict[int, t.Tuple[str, t.Optional[t.Tuple[str]]]]]] = {}
2526

2627

2728
def instrument_all_lines(code: CodeType, hook: HookType, path: str, package: str) -> t.Tuple[CodeType, CoverageLines]:
29+
"""
30+
Instrument code for coverage tracking using Python 3.12's monitoring API.
31+
32+
Args:
33+
code: The code object to instrument
34+
hook: The hook function to call
35+
path: The file path
36+
package: The package name
37+
38+
Note: Python 3.12+ uses an optimized approach where each line callback returns DISABLE
39+
after recording. This means:
40+
- Each line is only reported once per coverage context (test/suite)
41+
- No overhead for repeated line executions (e.g., in loops)
42+
- Full line-by-line coverage data is captured
43+
"""
2844
coverage_tool = sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID)
2945
if coverage_tool is not None and coverage_tool != "datadog":
3046
log.debug("Coverage tool '%s' already registered, not gathering coverage", coverage_tool)
@@ -37,10 +53,21 @@ def instrument_all_lines(code: CodeType, hook: HookType, path: str, package: str
3753
return _instrument_all_lines_with_monitoring(code, hook, path, package)
3854

3955

40-
def _line_event_handler(code: CodeType, line: int) -> t.Any:
41-
hook, path, import_names = _CODE_HOOKS[code]
56+
def _line_event_handler(code: CodeType, line: int) -> t.Literal[sys.monitoring.DISABLE]:
57+
hook_data = _CODE_HOOKS.get(code)
58+
if hook_data is None:
59+
return sys.monitoring.DISABLE
60+
61+
hook, path, import_names = hook_data
62+
63+
# Report the line and then disable monitoring for this specific line
64+
# This ensures each line is only reported once per context, even if executed multiple times (e.g., in loops)
4265
import_name = import_names.get(line, None)
43-
return hook((line, path, import_name))
66+
hook((line, path, import_name))
67+
68+
# Return DISABLE to prevent future callbacks for this specific line
69+
# This provides full line coverage with minimal overhead
70+
return sys.monitoring.DISABLE
4471

4572

4673
def _register_monitoring():
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
fixes:
3+
- |
4+
CI Visibility: This fix resolves performance issue affecting coverage collection for Python 3.12+
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Constants module - imported dynamically"""
2+
3+
# Module-level constants
4+
OFFSET = 10
5+
MULTIPLIER = 2
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Constants module - imported at top level"""
2+
3+
# Module-level constants
4+
MAX_VALUE = 100
5+
MIN_VALUE = 0
6+
DEFAULT_MULTIPLIER = 3
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Layer 2 - Imported dynamically, has its own imports"""
2+
3+
# Top-level import even though this module itself is imported dynamically
4+
from tests.coverage.included_path.layer3_toplevel import layer3_toplevel_function
5+
6+
7+
def layer2_dynamic_function(b):
8+
# Use top-level import
9+
step1 = layer3_toplevel_function(b)
10+
11+
# Dynamic imports - both function and constants
12+
from tests.coverage.included_path.constants_dynamic import OFFSET
13+
from tests.coverage.included_path.layer3_dynamic import layer3_dynamic_function
14+
15+
step2 = layer3_dynamic_function(step1)
16+
return step2 + OFFSET - 5
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Layer 2 - Has top-level import and dynamic import"""
2+
3+
# Top-level imports - both function and constants
4+
from tests.coverage.included_path.constants_toplevel import DEFAULT_MULTIPLIER
5+
from tests.coverage.included_path.layer3_toplevel import layer3_toplevel_function
6+
7+
8+
def layer2_toplevel_function(a):
9+
# Use the top-level imported function and constant
10+
intermediate = layer3_toplevel_function(a) * DEFAULT_MULTIPLIER
11+
12+
# Dynamic import inside function
13+
from tests.coverage.included_path.layer3_dynamic import layer3_dynamic_function
14+
15+
final = layer3_dynamic_function(intermediate)
16+
return final

0 commit comments

Comments
 (0)