Skip to content

Commit 4ca6009

Browse files
chore(internal): avoid freezegun in ddtrace (DataDog#11406)
`freezegun` (and `pytest-freezegun`) may cause wrong durations to be reported when used, so we add an integration that automatically enforces that `ddtrace` be an ignored module by: - calling `freezegun.configure()` with `extend_ignore_list` to add `ddtrace` - wrapping `freezegun.configure()` so that any attempt to reset the `default_ignore_list` includes `ddtrace` Additionally, since `pytest` does not patch all integrations by default, and because we don't want to force users to use `--ddtrace-patch-all` for `freezegun` to be patched, we update our `pytest` plugin(s) to patch `freezegun` during the `pytest_load_initial_conftests` hook (which should be early enough). This is not considered a fix as there are no active issues reported and this (so far) primarily affects telemetry recorded by the CI Visibility service. ## Checklist - [x] PR author has checked that all the criteria below are met - The PR description includes an overview of the change - The PR description articulates the motivation for the change - The change includes tests OR the PR description describes a testing strategy - The PR description notes risks associated with the change, if any - Newly-added code is easy to change - The change follows the [library release note guidelines](https://ddtrace.readthedocs.io/en/stable/releasenotes.html) - The change includes or references documentation updates if necessary - Backport labels are set (if [applicable](https://ddtrace.readthedocs.io/en/latest/contributing.html#backporting)) ## Reviewer Checklist - [x] Reviewer has checked that all the criteria below are met - Title is accurate - All changes are related to the pull request's stated goal - Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes - Testing strategy adequately addresses listed risks - Newly-added code is easy to change - Release note makes sense to a user of the library - If necessary, author has acknowledged and discussed the performance implications of this PR as reported in the benchmarks PR comment - 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)
1 parent aac073b commit 4ca6009

File tree

9 files changed

+248
-0
lines changed

9 files changed

+248
-0
lines changed

.github/CODEOWNERS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ tests/coverage @DataDog/apm-core-python @
7777
tests/tracer/test_ci.py @DataDog/ci-app-libraries
7878
ddtrace/ext/git.py @DataDog/ci-app-libraries @DataDog/apm-core-python
7979
scripts/ci_visibility/* @DataDog/ci-app-libraries
80+
# Test Visibility owns the freezegun integration because it's the team most affected by it
81+
ddtrace/contrib/freezegun @DataDog/ci-app-libraries
82+
ddtrace/contrib/internal/freezegun @DataDog/ci-app-libraries
83+
tests/contrib/freezegun @DataDog/ci-app-libraries
8084

8185
# Debugger
8286
ddtrace/debugging/ @DataDog/debugger-python

ddtrace/_monkey.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"elasticsearch": True,
4242
"algoliasearch": True,
4343
"futures": True,
44+
"freezegun": True,
4445
"google_generativeai": True,
4546
"gevent": True,
4647
"graphql": True,

ddtrace/contrib/freezegun/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
The freezegun integration reconfigures freezegun's default ignore list to ignore ddtrace.
3+
4+
Enabling
5+
~~~~~~~~
6+
The freezegun integration is enabled by default. Use :func:`patch()<ddtrace.patch>` to enable the integration::
7+
from ddtrace import patch
8+
patch(freezegun=True)
9+
10+
11+
Configuration
12+
~~~~~~~~~~~~~
13+
The freezegun integration is not configurable, but may be disabled using DD_PATCH_MODULES=freezegun:false .
14+
"""
15+
16+
from ...internal.utils.importlib import require_modules
17+
18+
19+
required_modules = ["freezegun"]
20+
21+
with require_modules(required_modules) as missing_modules:
22+
if not missing_modules:
23+
# Expose public methods
24+
from ..internal.freezegun.patch import get_version
25+
from ..internal.freezegun.patch import patch
26+
from ..internal.freezegun.patch import unpatch
27+
28+
__all__ = ["get_version", "patch", "unpatch"]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from ddtrace.internal.logger import get_logger
2+
from ddtrace.internal.wrapping.context import WrappingContext
3+
4+
5+
log = get_logger(__name__)
6+
7+
DDTRACE_MODULE_NAME = "ddtrace"
8+
9+
10+
class FreezegunConfigWrappingContext(WrappingContext):
11+
"""Wraps the call to freezegun.configure to ensure that ddtrace remains patched if default_ignore_list is passed
12+
in as an argument
13+
"""
14+
15+
# __exit__ comes from the parent class
16+
# no-dd-sa:python-best-practices/ctx-manager-enter-exit-defined
17+
def __enter__(self) -> "FreezegunConfigWrappingContext":
18+
super().__enter__()
19+
try:
20+
default_ignore_list = self.get_local("default_ignore_list")
21+
except KeyError:
22+
log.debug("Could not get default_ignore_list on call to configure()")
23+
return
24+
25+
if default_ignore_list is not None and DDTRACE_MODULE_NAME not in default_ignore_list:
26+
default_ignore_list.append(DDTRACE_MODULE_NAME)
27+
28+
return self
29+
30+
31+
def get_version() -> str:
32+
import freezegun
33+
34+
try:
35+
return freezegun.__version__
36+
except AttributeError:
37+
log.debug("Could not get freezegun version")
38+
return ""
39+
40+
41+
def patch() -> None:
42+
import freezegun
43+
44+
if getattr(freezegun, "_datadog_patch", False):
45+
return
46+
47+
FreezegunConfigWrappingContext(freezegun.configure).wrap()
48+
49+
freezegun.configure(extend_ignore_list=[DDTRACE_MODULE_NAME])
50+
51+
freezegun._datadog_patch = True
52+
53+
54+
def unpatch() -> None:
55+
import freezegun
56+
57+
if not getattr(freezegun, "_datadog_patch", False):
58+
return
59+
60+
if FreezegunConfigWrappingContext.is_wrapped(freezegun.configure):
61+
FreezegunConfigWrappingContext.extract(freezegun.configure).unwrap()
62+
63+
# Note: we do not want to restore to the original ignore list, as it may have been modified by the user, but we do
64+
# want to remove the ddtrace module from the ignore list
65+
new_ignore_list = [m for m in freezegun.config.settings.default_ignore_list if m != DDTRACE_MODULE_NAME]
66+
freezegun.configure(default_ignore_list=new_ignore_list)
67+
68+
freezegun._datadog_patch = False

ddtrace/contrib/pytest/_plugin_v1.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,9 @@ def pytest_load_initial_conftests(early_config, parser, args):
423423

424424
log = get_logger(__name__)
425425

426+
# Freezegun is proactively patched to avoid it interfering with internal timing
427+
ddtrace.patch(freezegun=True)
428+
426429
COVER_SESSION = asbool(os.environ.get("_DD_COVER_SESSION", "false"))
427430

428431
if USE_DD_COVERAGE:

ddtrace/contrib/pytest/_plugin_v2.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from ddtrace import DDTraceDeprecationWarning
88
from ddtrace import config as dd_config
9+
from ddtrace import patch
910
from ddtrace.contrib.coverage import patch as patch_coverage
1011
from ddtrace.contrib.internal.coverage.constants import PCT_COVERED_KEY
1112
from ddtrace.contrib.internal.coverage.data import _coverage_data
@@ -163,6 +164,8 @@ def pytest_load_initial_conftests(early_config, parser, args):
163164
try:
164165
take_over_logger_stream_handler()
165166
log.warning("This version of the ddtrace pytest plugin is currently in beta.")
167+
# Freezegun is proactively patched to avoid it interfering with internal timing
168+
patch(freezegun=True)
166169
dd_config.test_visibility.itr_skipping_level = ITR_SKIPPING_LEVEL.SUITE
167170
enable_test_visibility(config=dd_config.pytest)
168171
if InternalTestSession.should_collect_coverage():

hatch.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,3 +450,24 @@ dependencies = [
450450
view = [
451451
"python scripts/ci_visibility/view_snapshot.py {args:}",
452452
]
453+
454+
[envs.freezegun]
455+
template = "freezegun"
456+
dependencies = [
457+
"freezegun{matrix:freezegun}",
458+
"pytest",
459+
"pytest-cov",
460+
"hypothesis",
461+
]
462+
463+
[envs.freezegun.env-vars]
464+
DD_PYTEST_USE_NEW_PLUGIN_BETA = "true"
465+
466+
[envs.freezegun.scripts]
467+
test = [
468+
"pytest tests/contrib/freezegun {args:}",
469+
]
470+
471+
[[envs.freezegun.matrix]]
472+
python = ["3.7", "3.10", "3.12"]
473+
freezegun = ["~=1.3.0", "~=1.5.0"]
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import datetime
2+
import os
3+
import time
4+
5+
import pytest
6+
7+
from ddtrace import tracer as dd_tracer
8+
from ddtrace.internal.utils.time import StopWatch
9+
from tests.contrib.pytest.test_pytest import PytestTestCaseBase
10+
11+
12+
class TestFreezegunTestCase:
13+
@pytest.fixture(autouse=True)
14+
def _patch_freezegun(self):
15+
from ddtrace.contrib.freezegun import patch
16+
from ddtrace.contrib.freezegun import unpatch
17+
18+
patch()
19+
yield
20+
unpatch()
21+
22+
def test_freezegun_unpatch(self):
23+
import freezegun
24+
25+
from ddtrace.contrib.freezegun import unpatch
26+
27+
unpatch()
28+
29+
with freezegun.freeze_time("2020-01-01"):
30+
with dd_tracer.trace("freezegun.test") as span:
31+
time.sleep(1)
32+
33+
assert span.duration == 0
34+
35+
def test_freezegun_does_not_freeze_tracing(self):
36+
import freezegun
37+
38+
with freezegun.freeze_time("2020-01-01"):
39+
with dd_tracer.trace("freezegun.test") as span:
40+
time.sleep(1)
41+
42+
assert span.duration >= 1
43+
44+
def test_freezegun_fast_forward_does_not_affect_tracing(self):
45+
import freezegun
46+
47+
with freezegun.freeze_time("2020-01-01") as frozen_time:
48+
with dd_tracer.trace("freezegun.test") as span:
49+
time.sleep(1)
50+
frozen_time.tick(delta=datetime.timedelta(days=10))
51+
assert 1 <= span.duration <= 5
52+
53+
def test_freezegun_does_not_freeze_stopwatch(self):
54+
import freezegun
55+
56+
with freezegun.freeze_time("2020-01-01"):
57+
with StopWatch() as sw:
58+
time.sleep(1)
59+
assert sw.elapsed() >= 1
60+
61+
def test_freezegun_configure_default_ignore_list_continues_to_ignore_ddtrace(self):
62+
import freezegun
63+
64+
freezegun.configure(default_ignore_list=[])
65+
66+
with freezegun.freeze_time("2020-01-01"):
67+
with dd_tracer.trace("freezegun.test") as span:
68+
time.sleep(1)
69+
70+
assert span.duration >= 1
71+
72+
73+
class PytestFreezegunTestCase(PytestTestCaseBase):
74+
def test_freezegun_pytest_plugin(self):
75+
"""Tests that pytest's patching of freezegun in the v1 plugin version works"""
76+
import sys
77+
78+
from ddtrace.contrib.freezegun import unpatch
79+
80+
unpatch()
81+
if "freezegun" in sys.modules:
82+
del sys.modules["freezegun"]
83+
84+
py_file = self.testdir.makepyfile(
85+
"""
86+
import datetime
87+
import time
88+
89+
import freezegun
90+
91+
from ddtrace import tracer as dd_tracer
92+
93+
def test_pytest_patched_freezegun():
94+
with freezegun.freeze_time("2020-01-01"):
95+
with dd_tracer.trace("freezegun.test") as span:
96+
time.sleep(1)
97+
assert span.duration >= 1
98+
99+
"""
100+
)
101+
file_name = os.path.basename(py_file.strpath)
102+
self.inline_run("--ddtrace", "-s", file_name)
103+
spans = self.pop_spans()
104+
105+
assert len(spans) == 4
106+
for span in spans:
107+
assert span.get_tag("test.status") == "pass"

tests/contrib/suitespec.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,9 @@ components:
9797
- ddtrace/contrib/flask_cache/*
9898
- ddtrace/contrib/internal/flask_cache/*
9999
- ddtrace/contrib/flask_login/*
100+
freezegun:
101+
- ddtrace/contrib/freezegun/*
102+
- ddtrace/contrib/internal/freezegun/*
100103
futures:
101104
- ddtrace/contrib/futures/*
102105
- ddtrace/contrib/internal/futures/*
@@ -609,6 +612,16 @@ suites:
609612
- memcached
610613
- redis
611614
snapshot: true
615+
freezegun:
616+
paths:
617+
- '@bootstrap'
618+
- '@core'
619+
- '@contrib'
620+
- '@tracing'
621+
- '@freezegun'
622+
- tests/contrib/freezegun/*
623+
runner: hatch
624+
snapshot: true
612625
gevent:
613626
paths:
614627
- '@bootstrap'

0 commit comments

Comments
 (0)