diff --git a/ddtrace/profiling/collector/_lock.py b/ddtrace/profiling/collector/_lock.py index 9aaf304ab52..f0e75f7a161 100644 --- a/ddtrace/profiling/collector/_lock.py +++ b/ddtrace/profiling/collector/_lock.py @@ -320,6 +320,9 @@ class LockCollector(collector.CaptureSamplerCollector): PROFILED_LOCK_CLASS: Type[Any] MODULE: ModuleType # e.g., threading module PATCHED_LOCK_NAME: str # e.g., "Lock", "RLock", "Semaphore" + # Module file to check for internal lock detection (e.g., threading.__file__ or asyncio.locks.__file__) + # If None, defaults to threading.__file__ for backward compatibility + INTERNAL_MODULE_FILE: Optional[str] = None def __init__( self, @@ -354,27 +357,32 @@ def patch(self) -> None: self._original_lock = self._get_patch_target() original_lock: Any = self._original_lock # Capture non-None value + # Determine which module file to check for internal lock detection + internal_module_file: Optional[str] = self.INTERNAL_MODULE_FILE + if internal_module_file is None: + # Default to threading.__file__ for backward compatibility + import threading as threading_module + + internal_module_file = threading_module.__file__ + def _profiled_allocate_lock(*args: Any, **kwargs: Any) -> _ProfiledLock: """Simple wrapper that returns profiled locks. - Detects if the lock is being created from within threading.py stdlib + Detects if the lock is being created from within the stdlib module (i.e., internal to Semaphore/Condition) to avoid double-counting. """ - import threading as threading_module - - # Check if caller is from threading.py (internal lock) + # Check if caller is from the internal module (internal lock) is_internal: bool = False try: # Frame 0: _profiled_allocate_lock # Frame 1: _LockAllocatorWrapper.__call__ - # Frame 2: actual caller (threading.Lock() call site) + # Frame 2: actual caller (Lock() call site) caller_filename = sys._getframe(2).f_code.co_filename - threading_module_file = threading_module.__file__ - if threading_module_file and caller_filename: + if internal_module_file and caller_filename: # Normalize paths to handle symlinks and different path formats - caller_filename_normalized = os.path.normpath(os.path.realpath(caller_filename)) - threading_file_normalized = os.path.normpath(os.path.realpath(threading_module_file)) - is_internal = caller_filename_normalized == threading_file_normalized + caller_filename = os.path.normpath(os.path.realpath(caller_filename)) + internal_file = os.path.normpath(os.path.realpath(internal_module_file)) + is_internal = caller_filename == internal_file except (ValueError, AttributeError, OSError): pass diff --git a/ddtrace/profiling/collector/asyncio.py b/ddtrace/profiling/collector/asyncio.py index e9ea57070c4..879fed97ea3 100644 --- a/ddtrace/profiling/collector/asyncio.py +++ b/ddtrace/profiling/collector/asyncio.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio from types import ModuleType from typing import Type @@ -13,6 +15,10 @@ class _ProfiledAsyncioSemaphore(_lock._ProfiledLock): pass +class _ProfiledAsyncioBoundedSemaphore(_lock._ProfiledLock): + pass + + class AsyncioLockCollector(_lock.LockCollector): """Record asyncio.Lock usage.""" @@ -27,3 +33,11 @@ class AsyncioSemaphoreCollector(_lock.LockCollector): PROFILED_LOCK_CLASS: Type[_ProfiledAsyncioSemaphore] = _ProfiledAsyncioSemaphore MODULE: ModuleType = asyncio PATCHED_LOCK_NAME: str = "Semaphore" + + +class AsyncioBoundedSemaphoreCollector(_lock.LockCollector): + """Record asyncio.BoundedSemaphore usage.""" + + PROFILED_LOCK_CLASS: Type[_ProfiledAsyncioBoundedSemaphore] = _ProfiledAsyncioBoundedSemaphore + MODULE: ModuleType = asyncio + PATCHED_LOCK_NAME: str = "BoundedSemaphore" diff --git a/ddtrace/profiling/profiler.py b/ddtrace/profiling/profiler.py index e15d9232d21..85b559412bc 100644 --- a/ddtrace/profiling/profiler.py +++ b/ddtrace/profiling/profiler.py @@ -221,6 +221,7 @@ def start_collector(collector_class: Type[collector.Collector]) -> None: ("threading", lambda _: start_collector(threading.ThreadingBoundedSemaphoreCollector)), ("asyncio", lambda _: start_collector(asyncio.AsyncioLockCollector)), ("asyncio", lambda _: start_collector(asyncio.AsyncioSemaphoreCollector)), + ("asyncio", lambda _: start_collector(asyncio.AsyncioBoundedSemaphoreCollector)), ] for module, hook in self._collectors_on_import: diff --git a/releasenotes/notes/Added-support-for-profiling-of-asyncio.BoundedSemaphore-objects-to-the-Python-Lock-profiler-5bf06391b0b047f1.yaml b/releasenotes/notes/Added-support-for-profiling-of-asyncio.BoundedSemaphore-objects-to-the-Python-Lock-profiler-5bf06391b0b047f1.yaml new file mode 100644 index 00000000000..72b7bc7c532 --- /dev/null +++ b/releasenotes/notes/Added-support-for-profiling-of-asyncio.BoundedSemaphore-objects-to-the-Python-Lock-profiler-5bf06391b0b047f1.yaml @@ -0,0 +1,4 @@ +--- +features: + - | + profiling: Add support for ``asyncio.BoundedSemaphore`` lock type profiling in Python Lock Profiler. diff --git a/tests/profiling/collector/test_asyncio.py b/tests/profiling/collector/test_asyncio.py index d8accf2f3bf..aae9e845968 100644 --- a/tests/profiling/collector/test_asyncio.py +++ b/tests/profiling/collector/test_asyncio.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import _thread import asyncio import glob @@ -13,6 +15,7 @@ from ddtrace import ext from ddtrace.internal.datadog.profiling import ddup +from ddtrace.profiling.collector.asyncio import AsyncioBoundedSemaphoreCollector from ddtrace.profiling.collector.asyncio import AsyncioLockCollector from ddtrace.profiling.collector.asyncio import AsyncioSemaphoreCollector from tests.profiling.collector import pprof_utils @@ -25,12 +28,12 @@ PY_311_OR_ABOVE = sys.version_info[:2] >= (3, 11) -# Type aliases for collector and lock types -CollectorType = Union[ - Type[AsyncioLockCollector], - Type[AsyncioSemaphoreCollector], -] -LockType = Union[Type[asyncio.Lock], Type[asyncio.Semaphore]] +# Type aliases for supported classes +LockTypeInst = Union[asyncio.Lock, asyncio.Semaphore, asyncio.BoundedSemaphore] +LockTypeClass = Type[LockTypeInst] + +CollectorTypeInst = Union[AsyncioLockCollector, AsyncioSemaphoreCollector, AsyncioBoundedSemaphoreCollector] +CollectorTypeClass = Type[CollectorTypeInst] @pytest.mark.parametrize( @@ -44,9 +47,13 @@ AsyncioSemaphoreCollector, "AsyncioSemaphoreCollector(status=, capture_pct=1.0, nframes=64, tracer=None)", # noqa: E501 ), + ( + AsyncioBoundedSemaphoreCollector, + "AsyncioBoundedSemaphoreCollector(status=, capture_pct=1.0, nframes=64, tracer=None)", # noqa: E501 + ), ], ) -def test_collector_repr(collector_class: CollectorType, expected_repr: str) -> None: +def test_collector_repr(collector_class: CollectorTypeClass, expected_repr: str) -> None: test_collector._test_repr(collector_class, expected_repr) @@ -57,15 +64,14 @@ class BaseAsyncioLockCollectorTest: Child classes must implement: - collector_class: The collector class to test - lock_class: The asyncio lock class to test - - lock_init_args: Arguments to pass to lock constructor (default: ()) """ @property - def collector_class(self) -> CollectorType: + def collector_class(self) -> CollectorTypeClass: raise NotImplementedError("Child classes must implement collector_class") @property - def lock_class(self) -> LockType: + def lock_class(self) -> LockTypeClass: raise NotImplementedError("Child classes must implement lock_class") def setup_method(self, method: Callable[..., Any]) -> None: @@ -231,3 +237,29 @@ def collector_class(self) -> Type[AsyncioSemaphoreCollector]: @property def lock_class(self) -> Type[asyncio.Semaphore]: return asyncio.Semaphore + + +class TestAsyncioBoundedSemaphoreCollector(BaseAsyncioLockCollectorTest): + """Test asyncio.BoundedSemaphore profiling.""" + + @property + def collector_class(self) -> Type[AsyncioBoundedSemaphoreCollector]: + return AsyncioBoundedSemaphoreCollector + + @property + def lock_class(self) -> Type[asyncio.BoundedSemaphore]: + return asyncio.BoundedSemaphore + + async def test_bounded_behavior_preserved(self) -> None: + """Test that profiling wrapper preserves BoundedSemaphore's bounded behavior. + + This verifies the wrapper doesn't interfere with BoundedSemaphore's unique characteristic: + raising ValueError when releasing beyond the initial value. + """ + with self.collector_class(capture_pct=100): + bs = asyncio.BoundedSemaphore(1) + await bs.acquire() + bs.release() + # BoundedSemaphore should raise ValueError when releasing more than initial value + with pytest.raises(ValueError, match="BoundedSemaphore released too many times"): + bs.release() diff --git a/tests/profiling/collector/test_threading.py b/tests/profiling/collector/test_threading.py index 9213d85c9ff..ff42782c5ea 100644 --- a/tests/profiling/collector/test_threading.py +++ b/tests/profiling/collector/test_threading.py @@ -1,4 +1,4 @@ -from __future__ import absolute_import +from __future__ import annotations import _thread import glob @@ -36,26 +36,16 @@ PY_311_OR_ABOVE = sys.version_info[:2] >= (3, 11) - -# Type aliases for supported classes -LockTypeClass = Union[ - Type[threading.Lock], Type[threading.RLock], Type[threading.Semaphore], Type[threading.BoundedSemaphore] -] # threading.Lock and threading.RLock are factory functions that return _thread types. # We reference the underlying _thread types directly to avoid creating instances at import time. -# threading.Semaphore and threading.BoundedSemaphore are Python classes, not factory functions. LockTypeInst = Union[_thread.LockType, _thread.RLock, threading.Semaphore, threading.BoundedSemaphore] +LockTypeClass = Type[LockTypeInst] -CollectorTypeClass = Union[ - Type[ThreadingLockCollector], - Type[ThreadingRLockCollector], - Type[ThreadingSemaphoreCollector], - Type[ThreadingBoundedSemaphoreCollector], -] # Type alias for collector instances CollectorTypeInst = Union[ ThreadingLockCollector, ThreadingRLockCollector, ThreadingSemaphoreCollector, ThreadingBoundedSemaphoreCollector ] +CollectorTypeClass = Type[CollectorTypeInst] # Module-level globals for testing global lock profiling diff --git a/tests/profiling/test_profiler.py b/tests/profiling/test_profiler.py index bac9d71303c..55a2884dbba 100644 --- a/tests/profiling/test_profiler.py +++ b/tests/profiling/test_profiler.py @@ -139,6 +139,7 @@ def test_default_collectors(): else: assert any(isinstance(c, asyncio.AsyncioLockCollector) for c in p._profiler._collectors) assert any(isinstance(c, asyncio.AsyncioSemaphoreCollector) for c in p._profiler._collectors) + assert any(isinstance(c, asyncio.AsyncioBoundedSemaphoreCollector) for c in p._profiler._collectors) p.stop(flush=False)