Skip to content
Open
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
13 changes: 13 additions & 0 deletions benchmarks/coverage_fibonacci/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Coverage benchmark configurations for fibonacci code
# Tests sys.monitoring.DISABLE optimization performance

small: &base
fib_n_recursive: 10

medium:
<<: *base
fib_n_recursive: 15

large:
<<: *base
fib_n_recursive: 20
51 changes: 51 additions & 0 deletions benchmarks/coverage_fibonacci/scenario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""
Benchmark for coverage collection on recursive code.

This benchmark ensures that the sys.monitoring.DISABLE optimization
doesn't regress. The DISABLE return value prevents the handler from being
called repeatedly for the same line in recursive functions and loops.

Without DISABLE: Handler called on every line execution
With DISABLE: Handler called once per unique line
"""

from typing import Callable
from typing import Generator

import bm


class CoverageFibonacci(bm.Scenario):
"""
Benchmark coverage collection performance on recursive and iterative code.

Tests the DISABLE optimization: returning sys.monitoring.DISABLE prevents
the handler from being called repeatedly for the same line.
"""

fib_n_recursive: int

def run(self) -> Generator[Callable[[int], None], None, None]:
import os
from pathlib import Path

from ddtrace.internal.coverage.code import ModuleCodeCollector
from ddtrace.internal.coverage.installer import install

# Install coverage
install(include_paths=[Path(os.getcwd())])

# Import after installation
from utils import fibonacci_recursive

def _(loops: int) -> None:
for _ in range(loops):
# Use coverage context to simulate real pytest per-test coverage
with ModuleCodeCollector.CollectInContext():
# Recursive: Many function calls, same lines executed repeatedly
result = fibonacci_recursive(self.fib_n_recursive)

# Verify correctness (don't optimize away)
assert result > 0

yield _
7 changes: 7 additions & 0 deletions benchmarks/coverage_fibonacci/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#!/usr/bin/env python


def fibonacci_recursive(n):
if n <= 1:
return n
return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
6 changes: 6 additions & 0 deletions ddtrace/internal/coverage/code.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from copy import deepcopy
from inspect import getmodule
import os
import sys
from types import CodeType
from types import ModuleType
import typing as t
Expand Down Expand Up @@ -225,6 +226,11 @@ def __enter__(self):
if self.is_import_coverage:
ctx_is_import_coverage.set(self.is_import_coverage)

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

return self

def __exit__(self, *args, **kwargs):
Expand Down
33 changes: 30 additions & 3 deletions ddtrace/internal/coverage/instrumentation_py3_12.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,26 @@
RETURN_CONST = dis.opmap["RETURN_CONST"]
EMPTY_MODULE_BYTES = bytes([RESUME, 0, RETURN_CONST, 0])

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


def instrument_all_lines(code: CodeType, hook: HookType, path: str, package: str) -> t.Tuple[CodeType, CoverageLines]:
"""
Instrument code for coverage tracking using Python 3.12's monitoring API.

Args:
code: The code object to instrument
hook: The hook function to call
path: The file path
package: The package name

Note: Python 3.12+ uses an optimized approach where each line callback returns DISABLE
after recording. This means:
- Each line is only reported once per coverage context (test/suite)
- No overhead for repeated line executions (e.g., in loops)
- Full line-by-line coverage data is captured
"""
coverage_tool = sys.monitoring.get_tool(sys.monitoring.COVERAGE_ID)
if coverage_tool is not None and coverage_tool != "datadog":
log.debug("Coverage tool '%s' already registered, not gathering coverage", coverage_tool)
Expand All @@ -37,10 +53,21 @@ def instrument_all_lines(code: CodeType, hook: HookType, path: str, package: str
return _instrument_all_lines_with_monitoring(code, hook, path, package)


def _line_event_handler(code: CodeType, line: int) -> t.Any:
hook, path, import_names = _CODE_HOOKS[code]
def _line_event_handler(code: CodeType, line: int) -> t.Literal[sys.monitoring.DISABLE]:
hook_data = _CODE_HOOKS.get(code)
if hook_data is None:
return sys.monitoring.DISABLE

hook, path, import_names = hook_data

# Report the line and then disable monitoring for this specific line
# This ensures each line is only reported once per context, even if executed multiple times (e.g., in loops)
import_name = import_names.get(line, None)
return hook((line, path, import_name))
hook((line, path, import_name))

# Return DISABLE to prevent future callbacks for this specific line
# This provides full line coverage with minimal overhead
return sys.monitoring.DISABLE


def _register_monitoring():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
fixes:
- |
CI Visibility: This fix resolves performance issue affecting coverage collection for Python 3.12+
5 changes: 5 additions & 0 deletions tests/coverage/included_path/constants_dynamic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Constants module - imported dynamically"""

# Module-level constants
OFFSET = 10
MULTIPLIER = 2
6 changes: 6 additions & 0 deletions tests/coverage/included_path/constants_toplevel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants module - imported at top level"""

# Module-level constants
MAX_VALUE = 100
MIN_VALUE = 0
DEFAULT_MULTIPLIER = 3
16 changes: 16 additions & 0 deletions tests/coverage/included_path/layer2_dynamic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Layer 2 - Imported dynamically, has its own imports"""

# Top-level import even though this module itself is imported dynamically
from tests.coverage.included_path.layer3_toplevel import layer3_toplevel_function


def layer2_dynamic_function(b):
# Use top-level import
step1 = layer3_toplevel_function(b)

# Dynamic imports - both function and constants
from tests.coverage.included_path.constants_dynamic import OFFSET
from tests.coverage.included_path.layer3_dynamic import layer3_dynamic_function

step2 = layer3_dynamic_function(step1)
return step2 + OFFSET - 5
16 changes: 16 additions & 0 deletions tests/coverage/included_path/layer2_toplevel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Layer 2 - Has top-level import and dynamic import"""

# Top-level imports - both function and constants
from tests.coverage.included_path.constants_toplevel import DEFAULT_MULTIPLIER
from tests.coverage.included_path.layer3_toplevel import layer3_toplevel_function


def layer2_toplevel_function(a):
# Use the top-level imported function and constant
intermediate = layer3_toplevel_function(a) * DEFAULT_MULTIPLIER

# Dynamic import inside function
from tests.coverage.included_path.layer3_dynamic import layer3_dynamic_function

final = layer3_dynamic_function(intermediate)
return final
6 changes: 6 additions & 0 deletions tests/coverage/included_path/layer3_dynamic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Layer 3 - Deepest level, imported dynamically"""


def layer3_dynamic_function(y):
computed = y + 10
return computed * 2
6 changes: 6 additions & 0 deletions tests/coverage/included_path/layer3_toplevel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Layer 3 - Deepest level with only top-level code"""


def layer3_toplevel_function(x):
result = x * 3
return result
33 changes: 33 additions & 0 deletions tests/coverage/included_path/nested_fixture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""
Fixture code with complex nested imports.

This fixture has:
- Top-level imports
- Dynamic (function-level) imports
And the imported modules themselves have more imports (both top-level and dynamic)
"""

# Top-level imports
from tests.coverage.included_path.layer2_toplevel import layer2_toplevel_function


def fixture_toplevel_path(value):
"""Uses top-level imported function"""
result = layer2_toplevel_function(value)
return result


def fixture_dynamic_path(value):
"""Uses dynamically imported function"""
# Dynamic import at function level
from tests.coverage.included_path.layer2_dynamic import layer2_dynamic_function

result = layer2_dynamic_function(value)
return result


def fixture_mixed_path(value):
"""Uses both paths"""
result1 = fixture_toplevel_path(value)
result2 = fixture_dynamic_path(value)
return result1 + result2
39 changes: 39 additions & 0 deletions tests/coverage/included_path/reinstrumentation_test_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""
Simple test module for testing coverage re-instrumentation across contexts.

This module provides simple, predictable functions with known line numbers
to help test that coverage collection works correctly across multiple contexts.
"""


def simple_function(x, y):
"""A simple function with a few lines."""
result = x + y
return result


def function_with_loop(n):
"""A function with a loop to test repeated line execution."""
total = 0
for i in range(n):
total += i
return total


def function_with_branches(condition):
"""A function with branches to test different code paths."""
if condition:
result = "true_branch"
else:
result = "false_branch"
return result


def multi_line_function(a, b, c):
"""A function with multiple lines to test comprehensive coverage."""
step1 = a + b
step2 = step1 * c
step3 = step2 - a
step4 = step3 / (b if b != 0 else 1)
result = step4**2
return result
Loading
Loading