Skip to content

Commit c240aa2

Browse files
Remove 'wrapt' dependency from the Lock Profiler
1 parent 5950676 commit c240aa2

File tree

3 files changed

+129
-120
lines changed

3 files changed

+129
-120
lines changed

ddtrace/profiling/collector/_lock.py

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@
1717
from typing import Tuple
1818
from typing import Type
1919

20-
import wrapt
21-
2220
from ddtrace.internal.datadog.profiling import ddup
2321
from ddtrace.profiling import _threading
2422
from ddtrace.profiling import collector
@@ -34,22 +32,24 @@ def _current_thread() -> Tuple[int, str]:
3432
return thread_id, _threading.get_thread_name(thread_id)
3533

3634

37-
# We need to know if wrapt is compiled in C or not. If it's not using the C module, then the wrappers function will
38-
# appear in the stack trace and we need to hide it.
39-
WRAPT_C_EXT: bool
40-
if os.environ.get("WRAPT_DISABLE_EXTENSIONS"):
41-
WRAPT_C_EXT = False
42-
else:
43-
try:
44-
import wrapt._wrappers as _w # noqa: F401
45-
except ImportError:
46-
WRAPT_C_EXT = False
47-
else:
48-
WRAPT_C_EXT = True
49-
del _w
35+
class _ProfiledLock:
36+
"""Lightweight lock wrapper that profiles lock acquire/release operations.
37+
38+
This is a simple delegating wrapper that intercepts lock methods without
39+
the overhead of a full proxy object.
40+
"""
5041

42+
__slots__ = (
43+
"__wrapped__",
44+
"_self_tracer",
45+
"_self_max_nframes",
46+
"_self_capture_sampler",
47+
"_self_endpoint_collection_enabled",
48+
"_self_init_loc",
49+
"_self_acquired_at",
50+
"_self_name",
51+
)
5152

52-
class _ProfiledLock(wrapt.ObjectProxy):
5353
def __init__(
5454
self,
5555
wrapped: Any,
@@ -58,12 +58,13 @@ def __init__(
5858
capture_sampler: collector.CaptureSampler,
5959
endpoint_collection_enabled: bool,
6060
) -> None:
61-
wrapt.ObjectProxy.__init__(self, wrapped)
61+
self.__wrapped__: Any = wrapped
6262
self._self_tracer: Optional[Tracer] = tracer
6363
self._self_max_nframes: int = max_nframes
6464
self._self_capture_sampler: collector.CaptureSampler = capture_sampler
6565
self._self_endpoint_collection_enabled: bool = endpoint_collection_enabled
66-
frame: FrameType = sys._getframe(2 if WRAPT_C_EXT else 3)
66+
# Frame depth: 0=__init__, 1=_profiled_allocate_lock, 2=_LockAllocatorWrapper.__call__, 3=caller
67+
frame: FrameType = sys._getframe(3)
6768
code: CodeType = frame.f_code
6869
self._self_init_loc: str = "%s:%d" % (os.path.basename(code.co_filename), frame.f_lineno)
6970
self._self_acquired_at: int = 0
@@ -134,11 +135,6 @@ def acquire(self, *args: Any, **kwargs: Any) -> Any:
134135
return self._acquire(self.__wrapped__.acquire, *args, **kwargs)
135136

136137
def _release(self, inner_func: Callable[..., Any], *args: Any, **kwargs: Any) -> None:
137-
# The underlying threading.Lock class is implemented using C code, and
138-
# it doesn't have the __dict__ attribute. So we can't do
139-
# self.__dict__.pop("_self_acquired_at", None) to remove the attribute.
140-
# Instead, we need to use the following workaround to retrieve and
141-
# remove the attribute.
142138
start: Optional[int] = getattr(self, "_self_acquired_at", None)
143139
try:
144140
# Though it should generally be avoided to call release() from
@@ -213,9 +209,14 @@ def _find_self_name(self, var_dict: Dict[str, Any]) -> Optional[str]:
213209
return name
214210
if config.lock.name_inspect_dir:
215211
for attribute in dir(value):
216-
if not attribute.startswith("__") and getattr(value, attribute) is self:
217-
self._self_name = attribute
218-
return attribute
212+
try:
213+
if not attribute.startswith("__") and getattr(value, attribute) is self:
214+
self._self_name = attribute
215+
return attribute
216+
except AttributeError:
217+
# With __slots__, accessing unset attributes raises AttributeError
218+
# (e.g., _self_acquired_at after it's been deleted in _release)
219+
continue
219220
return None
220221

221222
# Get lock acquire/release call location and variable name the lock is assigned to
@@ -251,12 +252,38 @@ def _maybe_update_self_name(self) -> None:
251252
if not self._self_name:
252253
self._self_name = ""
253254

255+
# Delegate remaining lock methods to the wrapped lock
256+
def locked(self) -> bool:
257+
"""Return True if lock is currently held."""
258+
return self.__wrapped__.locked()
259+
260+
def __repr__(self) -> str:
261+
return f"<_ProfiledLock({self.__wrapped__!r}) at {self._self_init_loc}>"
262+
263+
# Support for being used in with statements
264+
def __bool__(self) -> bool:
265+
return True
266+
267+
268+
class _LockAllocatorWrapper:
269+
"""Wrapper for lock allocator functions that prevents method binding.
270+
271+
When a function is stored as a class attribute and accessed via an instance,
272+
Python's descriptor protocol normally binds it as a method. This wrapper
273+
prevents that behavior by implementing __get__ to always return self,
274+
similar to how staticmethod works, but as a callable object.
275+
"""
276+
277+
__slots__ = ("_func",)
278+
279+
def __init__(self, func: Callable[..., Any]) -> None:
280+
self._func: Callable[..., Any] = func
281+
282+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
283+
return self._func(*args, **kwargs)
254284

255-
class FunctionWrapper(wrapt.FunctionWrapper):
256-
# Override the __get__ method: whatever happens, _allocate_lock is always considered by Python like a "static"
257-
# method, even when used as a class attribute. Python never tried to "bind" it to a method, because it sees it is a
258-
# builtin function. Override default wrapt behavior here that tries to detect bound method.
259-
def __get__(self, instance: Any, owner: Optional[Type] = None) -> FunctionWrapper: # type: ignore
285+
def __get__(self, instance: Any, owner: Optional[Type] = None) -> _LockAllocatorWrapper:
286+
# Always return self, never bind as a method
260287
return self
261288

262289

@@ -303,9 +330,9 @@ def patch(self) -> None:
303330
# Nobody should use locks from `_thread`; if they do so, then it's deliberate and we don't profile.
304331
self._original = self._get_patch_target()
305332

306-
# TODO: `instance` is unused
307-
def _allocate_lock(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> _ProfiledLock:
308-
lock: Any = wrapped(*args, **kwargs)
333+
# Create a simple wrapper function that returns profiled locks
334+
def _profiled_allocate_lock(*args: Any, **kwargs: Any) -> _ProfiledLock:
335+
lock: Any = self._original(*args, **kwargs)
309336
return self.PROFILED_LOCK_CLASS(
310337
lock,
311338
self.tracer,
@@ -314,7 +341,9 @@ def _allocate_lock(wrapped: Any, instance: Any, args: Any, kwargs: Any) -> _Prof
314341
self.endpoint_collection_enabled,
315342
)
316343

317-
self._set_patch_target(FunctionWrapper(self._original, _allocate_lock))
344+
# Wrap the function to prevent it from being bound as a method when
345+
# accessed as a class attribute (e.g., Foo.lock_class = threading.Lock)
346+
self._set_patch_target(_LockAllocatorWrapper(_profiled_allocate_lock))
318347

319348
def unpatch(self) -> None:
320349
"""Unpatch the threading module for tracking lock allocation."""
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
other:
3+
- |
4+
profiling: This removes the ``wrapt`` library dependency from the Lock Profiler implementation, improving performance and reducing overhead during lock instrumentation.

tests/profiling_v2/collector/test_threading.py

Lines changed: 61 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from __future__ import absolute_import
2+
13
import _thread
24
import glob
35
import os
6+
import sys
47
import threading
58
from typing import Callable
69
from typing import List
@@ -88,94 +91,67 @@ def test_repr(
8891
test_collector._test_repr(collector_class, expected_repr)
8992

9093

91-
@pytest.mark.parametrize(
92-
"lock_class,collector_class",
93-
[
94-
(threading.Lock, ThreadingLockCollector),
95-
(threading.RLock, ThreadingRLockCollector),
96-
],
97-
)
98-
def test_patch(
99-
lock_class: LockClassType,
100-
collector_class: CollectorClassType,
101-
) -> None:
102-
lock: LockClassType = lock_class
103-
collector: ThreadingLockCollector | ThreadingRLockCollector = collector_class()
94+
def test_patch():
95+
from ddtrace.profiling.collector._lock import _LockAllocatorWrapper
96+
97+
lock = threading.Lock
98+
collector = ThreadingLockCollector()
10499
collector.start()
105100
assert lock == collector._original
106-
# wrapt makes this true
107-
assert lock == lock_class
101+
# After patching, threading.Lock is replaced with our wrapper
102+
# The old reference (lock) points to the original builtin Lock class
103+
assert lock != threading.Lock # They're different after patching
104+
assert isinstance(threading.Lock, _LockAllocatorWrapper) # threading.Lock is now wrapped
105+
assert callable(threading.Lock) # and it's callable
108106
collector.stop()
109-
assert lock == lock_class
110-
assert collector._original == lock_class
111-
112-
113-
@pytest.mark.subprocess(
114-
env=dict(WRAPT_DISABLE_EXTENSIONS="True", DD_PROFILING_FILE_PATH=__file__),
115-
)
116-
def test_wrapt_disable_extensions() -> None:
117-
import os
118-
import threading
119-
120-
from ddtrace.internal.datadog.profiling import ddup
121-
from ddtrace.profiling.collector import _lock
122-
from ddtrace.profiling.collector.threading import ThreadingLockCollector
123-
from tests.profiling.collector import pprof_utils
124-
from tests.profiling.collector.lock_utils import LineNo
125-
from tests.profiling.collector.lock_utils import get_lock_linenos
126-
from tests.profiling.collector.lock_utils import init_linenos
127-
from tests.profiling.collector.pprof_utils import pprof_pb2
128-
129-
assert ddup.is_available, "ddup is not available"
130-
131-
# Set up the ddup exporter
132-
test_name: str = "test_wrapt_disable_extensions"
133-
pprof_prefix: str = "/tmp" + os.sep + test_name
134-
output_filename: str = pprof_prefix + "." + str(os.getpid())
135-
ddup.config(
136-
env="test", service=test_name, version="my_version", output_filename=pprof_prefix
137-
) # pyright: ignore[reportCallIssue]
138-
ddup.start()
139-
140-
init_linenos(os.environ["DD_PROFILING_FILE_PATH"])
141-
142-
# WRAPT_DISABLE_EXTENSIONS is a flag that can be set to disable the C extension
143-
# for wrapt. It's not set by default in dd-trace-py, but it can be set by
144-
# users. This test checks that the collector works even if the flag is set.
145-
assert os.environ.get("WRAPT_DISABLE_EXTENSIONS")
146-
assert _lock.WRAPT_C_EXT is False
147-
148-
with ThreadingLockCollector(capture_pct=100):
149-
th_lock: threading.Lock = threading.Lock() # !CREATE! test_wrapt_disable_extensions
150-
with th_lock: # !ACQUIRE! !RELEASE! test_wrapt_disable_extensions
151-
pass
152-
153-
ddup.upload() # pyright: ignore[reportCallIssue]
154-
155-
expected_filename: str = "test_threading.py"
156-
157-
linenos: LineNo = get_lock_linenos("test_wrapt_disable_extensions", with_stmt=True)
158-
159-
profile: pprof_pb2.Profile = pprof_utils.parse_newest_profile(output_filename)
160-
pprof_utils.assert_lock_events(
161-
profile,
162-
expected_acquire_events=[
163-
pprof_utils.LockAcquireEvent(
164-
caller_name="<module>",
165-
filename=expected_filename,
166-
linenos=linenos,
167-
lock_name="th_lock",
168-
)
169-
],
170-
expected_release_events=[
171-
pprof_utils.LockReleaseEvent(
172-
caller_name="<module>",
173-
filename=expected_filename,
174-
linenos=linenos,
175-
lock_name="th_lock",
176-
)
177-
],
178-
)
107+
# After stopping, everything is restored
108+
assert lock == threading.Lock
109+
assert collector._original == threading.Lock
110+
111+
112+
@pytest.mark.skipif(not sys.platform.startswith("linux"), reason="only works on linux")
113+
@pytest.mark.subprocess(err=None)
114+
# For macOS: Could print 'Error uploading' but okay to ignore since we are checking if native_id is set
115+
def test_user_threads_have_native_id():
116+
from os import getpid
117+
from threading import Thread
118+
from threading import _MainThread
119+
from threading import current_thread
120+
from time import sleep
121+
122+
from ddtrace.profiling import profiler
123+
124+
# DEV: We used to run this test with ddtrace_run=True passed into the
125+
# subprocess decorator, but that caused this to be flaky for Python 3.8.x
126+
# with gevent. When it failed for that specific venv, current_thread()
127+
# returned a DummyThread instead of a _MainThread.
128+
p = profiler.Profiler()
129+
p.start()
130+
131+
main = current_thread()
132+
assert isinstance(main, _MainThread)
133+
# We expect the current thread to have the same ID as the PID
134+
assert main.native_id == getpid(), (main.native_id, getpid())
135+
136+
t = Thread(target=lambda: None)
137+
t.start()
138+
139+
for _ in range(10):
140+
try:
141+
# The TID should be higher than the PID, but not too high
142+
assert 0 < t.native_id - getpid() < 100, (t.native_id, getpid())
143+
except AttributeError:
144+
# The native_id attribute is set by the thread so we might have to
145+
# wait a bit for it to be set.
146+
sleep(0.1)
147+
else:
148+
break
149+
else:
150+
raise AssertionError("Thread.native_id not set")
151+
152+
t.join()
153+
154+
p.stop()
179155

180156

181157
# This test has to be run in a subprocess because it calls gevent.monkey.patch_all()

0 commit comments

Comments
 (0)