Skip to content
Draft
28 changes: 18 additions & 10 deletions ddtrace/profiling/collector/_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down
14 changes: 14 additions & 0 deletions ddtrace/profiling/collector/asyncio.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import asyncio
from types import ModuleType
from typing import Type
Expand All @@ -13,6 +15,10 @@ class _ProfiledAsyncioSemaphore(_lock._ProfiledLock):
pass


class _ProfiledAsyncioBoundedSemaphore(_lock._ProfiledLock):
pass


class AsyncioLockCollector(_lock.LockCollector):
"""Record asyncio.Lock usage."""

Expand All @@ -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"
1 change: 1 addition & 0 deletions ddtrace/profiling/profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
features:
- |
profiling: Add support for ``asyncio.BoundedSemaphore`` lock type profiling in Python Lock Profiler.
52 changes: 42 additions & 10 deletions tests/profiling/collector/test_asyncio.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import _thread
import asyncio
import glob
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -44,9 +47,13 @@
AsyncioSemaphoreCollector,
"AsyncioSemaphoreCollector(status=<ServiceStatus.STOPPED: 'stopped'>, capture_pct=1.0, nframes=64, tracer=None)", # noqa: E501
),
(
AsyncioBoundedSemaphoreCollector,
"AsyncioBoundedSemaphoreCollector(status=<ServiceStatus.STOPPED: 'stopped'>, 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)


Expand All @@ -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:
Expand Down Expand Up @@ -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()
16 changes: 6 additions & 10 deletions tests/profiling/collector/test_threading.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from __future__ import absolute_import
from __future__ import annotations

import _thread
import glob
Expand Down Expand Up @@ -38,24 +38,20 @@


# 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 = Union[
# Type[threading.Lock], Type[threading.RLock], Type[threading.Semaphore], Type[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
Expand Down
1 change: 1 addition & 0 deletions tests/profiling/test_profiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading