Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .github/workflows/generate-package-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ jobs:
with:
python-version: "3.14"

- name: Setup Python 3.15
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.15"

- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0

Expand Down
16 changes: 16 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -703,6 +703,22 @@ profiling_native:
- PYTHON_VERSION: ["3.12", "3.14"]
SANITIZER: ["valgrind"]

# AIDEV-TODO(py315): fold 3.15 back into the profiling_native matrix once the
# dd/images/dd-trace-py/profiling_native base image installs a Python 3.15
# pyenv version. Today it only carries 3.9–3.14, so PYENV_VERSION=3.15 fails
# with "pyenv: version `3.15' is not installed". allow_failure keeps the
# pipeline green while the upstream image catches up; delete this job and
# re-add "3.15" to the PYTHON_VERSION arrays above once the image is updated.
profiling_native_py315:
extends: .profiling_native_base
allow_failure: true
retry: 2
rules: !reference [profiling_native, rules]
parallel:
matrix:
- PYTHON_VERSION: ["3.15"]
SANITIZER: ["safety", "thread", "", "valgrind"]

test-dd-sts:
stage: tests
needs: []
Expand Down
2 changes: 2 additions & 0 deletions .gitlab/templates/build-base-venvs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ build_base_venvs:
- core.*
- ddtrace/**/*.so*
- .riot/venv_*
- ddtrace/internal/datadog/profiling/test/test_*
- ddtrace/internal/datadog/profiling/test/py315/test_*
34 changes: 34 additions & 0 deletions .riot/requirements/1c6cb02.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#
# This file is autogenerated by pip-compile with Python 3.15
# by the following command:
#
# pip-compile --allow-unsafe --no-annotate .riot/requirements/1c6cb02.in
#
attrs==26.1.0
coverage[toml]==7.13.5
gunicorn==25.3.0
hypothesis==6.45.0
iniconfig==2.3.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mock==5.2.0
numpy==2.4.4
opentracing==2.4.0
packaging==26.1
pluggy==1.6.0
protobuf==7.34.1
py-cpuinfo==8.0.0
pygments==2.20.0
pytest==9.0.3
pytest-asyncio==0.21.1
pytest-benchmark==5.2.3
pytest-cov==7.1.0
pytest-cpp==2.6.0
pytest-mock==3.15.1
pytest-randomly==4.1.0
referencing==0.37.0
rpds-py==0.30.0
sortedcontainers==2.4.0
uvloop==0.22.1
uwsgi==2.0.31
zstandard==0.25.0
45 changes: 45 additions & 0 deletions .riot/requirements/222bcd0.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#
# This file is autogenerated by pip-compile with Python 3.15
# by the following command:
#
<<<<<<<< HEAD:.riot/requirements/222bcd0.txt
# pip-compile --allow-unsafe --no-annotate .riot/requirements/222bcd0.in
#
attrs==26.1.0
cloudpickle==3.1.2
coverage[toml]==7.14.3
execnet==2.1.2
gevent==26.5.0
greenlet==3.5.3
httpretty==1.1.4
========
# pip-compile --allow-unsafe --no-annotate .riot/requirements/1857594.in
#
attrs==26.1.0
coverage[toml]==7.13.5
>>>>>>>> 49c1ffeaab (ci(profiling): wire py3.15 into build matrix, riotfile, and CI):.riot/requirements/1857594.txt
hypothesis==6.45.0
iniconfig==2.3.0
mock==5.2.0
opentracing==2.4.0
packaging==26.1
pluggy==1.6.0
pyfakefs==6.2.0
pygments==2.20.0
pytest==8.4.2
pytest-asyncio==0.23.8
pytest-cov==7.1.0
pytest-mock==3.15.1
pytest-randomly==4.1.0
python-json-logger==2.0.7
sortedcontainers==2.4.0
<<<<<<<< HEAD:.riot/requirements/222bcd0.txt
uwsgi==2.0.31
wrapt==2.2.2
========
>>>>>>>> 49c1ffeaab (ci(profiling): wire py3.15 into build matrix, riotfile, and CI):.riot/requirements/1857594.txt
zope-event==5.0
zope-interface==7.2

# The following packages are considered to be unsafe in a requirements file:
setuptools==81.0.0
33 changes: 33 additions & 0 deletions .riot/requirements/95077af.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#
# This file is autogenerated by pip-compile with Python 3.15
# by the following command:
#
# pip-compile --allow-unsafe --no-annotate .riot/requirements/95077af.in
#
attrs==26.1.0
coverage[toml]==7.13.5
gunicorn==25.3.0
hypothesis==6.45.0
iniconfig==2.3.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mock==5.2.0
numpy==2.4.4
opentracing==2.4.0
packaging==26.1
pluggy==1.6.0
protobuf==7.34.1
py-cpuinfo==8.0.0
pygments==2.20.0
pytest==9.0.3
pytest-asyncio==0.21.1
pytest-benchmark==5.2.3
pytest-cov==7.1.0
pytest-cpp==2.6.0
pytest-mock==3.15.1
pytest-randomly==4.1.0
referencing==0.37.0
rpds-py==0.30.0
sortedcontainers==2.4.0
uwsgi==2.0.31
zstandard==0.25.0
37 changes: 37 additions & 0 deletions .riot/requirements/e26245b.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#
# This file is autogenerated by pip-compile with Python 3.15
# by the following command:
#
# pip-compile --allow-unsafe --no-annotate .riot/requirements/e26245b.in
#
attrs==26.1.0
coverage[toml]==7.13.5
gevent==26.4.0
greenlet==3.4.0
gunicorn[gevent]==25.3.0
hypothesis==6.45.0
iniconfig==2.3.0
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mock==5.2.0
numpy==2.4.4
opentracing==2.4.0
packaging==26.1
pluggy==1.6.0
protobuf==7.34.1
py-cpuinfo==8.0.0
pygments==2.20.0
pytest==9.0.3
pytest-asyncio==0.21.1
pytest-benchmark==5.2.3
pytest-cov==7.1.0
pytest-cpp==2.6.0
pytest-mock==3.15.1
pytest-randomly==4.1.0
referencing==0.37.0
rpds-py==0.30.0
sortedcontainers==2.4.0
uwsgi==2.0.31
zope-event==6.1
zope-interface==8.3
zstandard==0.25.0
15 changes: 14 additions & 1 deletion ddtrace/internal/monitoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ def _events_for_handler(handler: MonitoringEventHandler) -> int:
return events



class _Entry(NamedTuple):
handler: MonitoringEventHandler
events: int # pre-computed from _events_for_handler
Expand Down Expand Up @@ -274,7 +275,19 @@ def _on_py_line(code: CodeType, line_number: int) -> Optional[object]:


def _set_local_events(tool_id: int, code: CodeType, events: int) -> None:
sys.monitoring.set_local_events(tool_id, code, events)
# TODO(py-315): Pre-release Python 3.15 builds may reject PY_UNWIND
# as a local event. Fall back without it when the full set is invalid;
# PY_UNWIND is still registered as a global callback via _setup() so
# exception handling degrades gracefully rather than crashing.
try:
sys.monitoring.set_local_events(tool_id, code, events)
except ValueError:
fallback: int = events & ~_E.PY_UNWIND
if fallback != events:
sys.monitoring.set_local_events(tool_id, code, fallback)
else:
raise



def _rearm_local_events(tool_id: int, code: CodeType, events: int) -> None:
Expand Down
53 changes: 33 additions & 20 deletions ddtrace/profiling/_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,35 +119,48 @@ def _(

@partial(wrap, sys.modules["asyncio"].tasks._GatheringFuture.__init__)
def _(f: typing.Callable[..., None], args: tuple[typing.Any, ...], kwargs: dict[str, typing.Any]) -> None:
f(*args, **kwargs)
children: list[aio.Future[typing.Any]] = typing.cast(
"list[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 1, "children")
)
assert children is not None # nosec: assert is used for typing

# TODO(py-315): current_task() raises RuntimeError on Python 3.15+ when there
# is no running event loop (e.g. asyncio.gather() called outside an async
# context to build a coroutine for later scheduling). In that case there is
# no parent task to link from, so we skip link_tasks entirely.
try:
return f(*args, **kwargs)
finally:
children: list[aio.Future[typing.Any]] = typing.cast(
"list[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 1, "children")
)
assert children is not None # nosec: assert is used for typing

if globals()["get_running_loop"]() is not None:
parent: typing.Optional[aio.Task[typing.Any]] = globals()["current_task"]()
if parent is not None:
for child in children:
stack.link_tasks(parent, child)
parent: typing.Optional[aio.Task[typing.Any]] = globals()["current_task"]()
except RuntimeError:
return
if parent is not None:
for child in children:
stack.link_tasks(parent, child)

@partial(wrap, sys.modules["asyncio"].tasks._wait)
def _(
f: typing.Callable[..., tuple[set[aio.Future[typing.Any]], set[aio.Future[typing.Any]]]],
args: tuple[typing.Any, ...],
kwargs: dict[str, typing.Any],
) -> typing.Any:
result: tuple[set[aio.Future[typing.Any]], set[aio.Future[typing.Any]]] = f(*args, **kwargs)
futures: set[aio.Future[typing.Any]] = typing.cast(
"set[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 0, "fs")
)

# TODO(py-315): same guard as the _GatheringFuture wrapper above — _wait may
# also be invoked outside a running loop. Skip link_tasks when current_task()
# raises.
try:
return f(*args, **kwargs)
finally:
futures = typing.cast("set[aio.Future[typing.Any]]", get_argument_value(args, kwargs, 0, "fs"))

if globals()["get_running_loop"]() is not None:
parent = typing.cast("aio.Task[typing.Any]", globals()["current_task"]())
for future in futures:
stack.link_tasks(parent, future)
parent: typing.Optional[aio.Task[typing.Any]] = typing.cast(
"aio.Task[typing.Any]", globals()["current_task"]()
)
except RuntimeError:
return result
if parent is not None:
for future in futures:
stack.link_tasks(parent, future)
return result

@partial(wrap, sys.modules["asyncio"].tasks.as_completed)
def _(
Expand Down
77 changes: 44 additions & 33 deletions ddtrace/profiling/collector/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,52 +3,63 @@
import asyncio
from types import ModuleType

from . import _lock

try:
from . import _lock

class _ProfiledAsyncioLock(_lock._ProfiledLock):
pass
class _ProfiledAsyncioLock(_lock._ProfiledLock):
pass

class _ProfiledAsyncioSemaphore(_lock._ProfiledLock):
pass

class _ProfiledAsyncioSemaphore(_lock._ProfiledLock):
pass
class _ProfiledAsyncioBoundedSemaphore(_lock._ProfiledLock):
pass

class _ProfiledAsyncioCondition(_lock._ProfiledLock):
pass

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

PROFILED_LOCK_CLASS: type[_ProfiledAsyncioLock] = _ProfiledAsyncioLock
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "Lock"

class _ProfiledAsyncioCondition(_lock._ProfiledLock):
pass
class AsyncioSemaphoreCollector(_lock.LockCollector):
"""Record asyncio.Semaphore usage."""

PROFILED_LOCK_CLASS: type[_ProfiledAsyncioSemaphore] = _ProfiledAsyncioSemaphore
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "Semaphore"

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

PROFILED_LOCK_CLASS: type[_ProfiledAsyncioLock] = _ProfiledAsyncioLock
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "Lock"
PROFILED_LOCK_CLASS: type[_ProfiledAsyncioBoundedSemaphore] = _ProfiledAsyncioBoundedSemaphore
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "BoundedSemaphore"

class AsyncioConditionCollector(_lock.LockCollector):
"""Record asyncio.Condition usage."""

class AsyncioSemaphoreCollector(_lock.LockCollector):
"""Record asyncio.Semaphore usage."""
PROFILED_LOCK_CLASS: type[_ProfiledAsyncioCondition] = _ProfiledAsyncioCondition
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "Condition"

PROFILED_LOCK_CLASS: type[_ProfiledAsyncioSemaphore] = _ProfiledAsyncioSemaphore
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "Semaphore"
except ImportError:
# TODO(py-315): _lock is a Cython extension that is not compiled for all Python
# versions (e.g. Python 3.15 before the manylinux image carries it). When it
# is absent the asyncio lock collectors are unavailable. Defining stubs that
# raise CollectorUnavailable lets profiler.py discover and gracefully skip them
# rather than failing at import time.
from ddtrace.profiling.collector import Collector as _Collector
from ddtrace.profiling.collector import CollectorUnavailable as _CollectorUnavailable

class AsyncioLockCollector(_Collector): # type: ignore[no-redef]
def start(self) -> None:
raise _CollectorUnavailable

class AsyncioBoundedSemaphoreCollector(_lock.LockCollector):
"""Record asyncio.BoundedSemaphore usage."""

PROFILED_LOCK_CLASS: type[_ProfiledAsyncioBoundedSemaphore] = _ProfiledAsyncioBoundedSemaphore
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "BoundedSemaphore"


class AsyncioConditionCollector(_lock.LockCollector):
"""Record asyncio.Condition usage."""

PROFILED_LOCK_CLASS: type[_ProfiledAsyncioCondition] = _ProfiledAsyncioCondition
MODULE: ModuleType = asyncio
PATCHED_LOCK_NAME: str = "Condition"
AsyncioSemaphoreCollector = AsyncioLockCollector # type: ignore[assignment,misc]
AsyncioBoundedSemaphoreCollector = AsyncioLockCollector # type: ignore[assignment,misc]
AsyncioConditionCollector = AsyncioLockCollector # type: ignore[assignment,misc]
Loading
Loading