Skip to content
Open
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
82 changes: 78 additions & 4 deletions sentry_sdk/integrations/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,35 @@ def _patched_close() -> None:
loop._sentry_flush_patched = True # type: ignore


def _get_eager_task_constructor(task_factory: "Any") -> "Optional[Callable[..., Any]]":
"""
Returns the task constructor of an eager task factory (Python 3.12+), and
None for any other task factory.
"""
# There is no public way to tell whether a task factory is eager, so
# libraries that need to know (anyio, for one) compare code objects: every
# factory returned by asyncio.create_eager_task_factory(),
# asyncio.eager_task_factory included, shares the same one.
eager_task_factory = getattr(asyncio, "eager_task_factory", None)
if (
eager_task_factory is None
or getattr(task_factory, "__code__", None) is not eager_task_factory.__code__
):
return None

# WARNING:
# This relies on create_eager_task_factory() closing over
# custom_task_constructor only. If asyncio changes that, the factory is
# treated like any other task factory.
try:
(cell,) = task_factory.__closure__
except (TypeError, ValueError):
return None

task_constructor = cell.cell_contents
return task_constructor if callable(task_constructor) else None


def _create_task_with_factory(
orig_task_factory: "Any",
loop: "asyncio.AbstractEventLoop",
Expand Down Expand Up @@ -131,6 +160,24 @@ def patch_asyncio() -> None:
if getattr(orig_task_factory, "_is_sentry_task_factory", False):
return

inner_task_factory: "Any" = orig_task_factory
eager_task_constructor = _get_eager_task_constructor(orig_task_factory)

if eager_task_constructor is not None:
# Create tasks with the original constructor, not the original
# factory. The factory makes every task eager unless it is told
# otherwise, which it can only be from Python 3.14, and a library
# that calls the constructor below directly does so to get a task
# that is not eager.
def _task_factory_from_constructor(
loop: "asyncio.AbstractEventLoop",
coro: "Coroutine[Any, Any, Any]",
**kwargs: "Any",
) -> "Any":
return eager_task_constructor(coro, loop=loop, **kwargs)

inner_task_factory = _task_factory_from_constructor

def _sentry_task_factory(
loop: "asyncio.AbstractEventLoop",
coro: "Coroutine[Any, Any, Any]",
Expand All @@ -139,7 +186,7 @@ def _sentry_task_factory(
# Check if this is an internal Sentry task
if is_internal_task():
return _create_task_with_factory(
orig_task_factory, loop, coro, **kwargs
inner_task_factory, loop, coro, **kwargs
)

@_wrap_coroutine(coro)
Expand Down Expand Up @@ -181,7 +228,7 @@ async def _task_with_sentry_span_creation() -> "Any":
return result

task = _create_task_with_factory(
orig_task_factory, loop, _task_with_sentry_span_creation(), **kwargs
inner_task_factory, loop, _task_with_sentry_span_creation(), **kwargs
)

# Set the task name to include the original coroutine's name
Expand All @@ -193,8 +240,35 @@ async def _task_with_sentry_span_creation() -> "Any":

return task

_sentry_task_factory._is_sentry_task_factory = True # type: ignore
loop.set_task_factory(_sentry_task_factory) # type: ignore
task_factory: "Any" = _sentry_task_factory

if eager_task_constructor is not None:
# Wrapping an eager task factory in a plain function hides that it
# is eager. Install a factory that is itself recognizable as eager
# and do the wrapping in its task constructor instead.
def _sentry_task_constructor(
coro: "Coroutine[Any, Any, Any]",
*,
loop: "asyncio.AbstractEventLoop",
**kwargs: "Any",
) -> "asyncio.Future[Any]":
task: "Any" = _sentry_task_factory(loop, coro, **kwargs)

# The eager factory always passes eager_start. Without it this
# is a direct call, and no create_task() follows to apply the
# name the caller asked for.
name = kwargs.get("name")
if "eager_start" not in kwargs and name is not None:
task.set_name(name)

return task

task_factory = asyncio.create_eager_task_factory( # type: ignore[attr-defined]
_sentry_task_constructor
)

task_factory._is_sentry_task_factory = True
loop.set_task_factory(task_factory)

except RuntimeError:
# When there is no running loop, we have nothing to patch.
Expand Down
135 changes: 135 additions & 0 deletions tests/integrations/asyncio/test_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@
)


minimum_python_312 = pytest.mark.skipif(
sys.version_info < (3, 12),
reason="Eager task factories were introduced in Python 3.12",
)


async def foo():
await asyncio.sleep(0.01)

Expand Down Expand Up @@ -898,3 +904,132 @@ def test_loop_close_flushes_async_transport(sentry_init):
loop.close()
if original_loop:
asyncio.set_event_loop(original_loop)


def run_with_eager_task_factory(sentry_init, main, task_factory=None):
"""
Runs main() in a new event loop that had an eager task factory when the
AsyncioIntegration was set up.
"""

async def runner():
loop = asyncio.get_running_loop()
loop.set_task_factory(task_factory or asyncio.eager_task_factory)
sentry_init(traces_sample_rate=1.0, integrations=[AsyncioIntegration()])
# setup_once() runs once per process, so patch this loop explicitly
patch_asyncio()
return await main()

return asyncio.run(runner())


@minimum_python_312
def test_eager_task_factory_stays_eager(sentry_init, capture_events):
steps = []

async def child():
steps.append("child started")
await asyncio.sleep(0)

async def main():
events = capture_events()
with sentry_sdk.start_transaction(name="test_transaction"):
task = asyncio.create_task(child())
steps.append("create_task returned")
await task
return events

events = run_with_eager_task_factory(sentry_init, main)

assert steps == ["child started", "create_task returned"]
(event,) = events
(span,) = event["spans"]
assert span["op"] == OP.FUNCTION
assert span["description"] == child.__qualname__


@minimum_python_312
def test_eager_task_factory_is_recognizable(sentry_init):
async def main():
return asyncio.get_running_loop().get_task_factory()

task_factory = run_with_eager_task_factory(sentry_init, main)

assert task_factory.__code__ is asyncio.eager_task_factory.__code__


@minimum_python_312
def test_eager_task_factory_keeps_custom_task_constructor(sentry_init):
class CustomTask(asyncio.Task):
pass

async def main():
task = asyncio.create_task(foo())
await task
return task

task = run_with_eager_task_factory(
sentry_init, main, asyncio.create_eager_task_factory(CustomTask)
)

assert isinstance(task, CustomTask)


@minimum_python_312
def test_eager_task_factory_patched_once(sentry_init):
async def main():
loop = asyncio.get_running_loop()
task_factory = loop.get_task_factory()
patch_asyncio()
return task_factory, loop.get_task_factory()

first, second = run_with_eager_task_factory(sentry_init, main)

assert first is second


@minimum_python_312
def test_eager_task_factory_with_anyio_task_group(sentry_init, capture_events):
anyio = pytest.importorskip("anyio")
steps = []

async def child(event):
steps.append(asyncio.current_task().get_name())
await event.wait()

async def main():
events = capture_events()
with sentry_sdk.start_transaction(name="test_transaction"):
async with anyio.create_task_group() as tg:
tg.start_soon(child, anyio.Event(), name="child task")
steps.append("start_soon returned")
await anyio.sleep(0.01)
tg.cancel_scope.cancel()
return events

events = run_with_eager_task_factory(sentry_init, main)

# anyio defers task group children itself, which it can only do if it
# recognizes the loop's task factory as eager
assert steps == ["start_soon returned", "child task"]
(event,) = events
assert len(event["spans"]) == 1


@minimum_python_312
def test_eager_task_factory_with_unexpected_closure(sentry_init, monkeypatch):
def task_factory(loop, coro, **kwargs):
return asyncio.Task(coro, loop=loop, **kwargs)

# Looks like an eager task factory, but holds no task constructor
monkeypatch.setattr(asyncio, "eager_task_factory", task_factory)

async def main():
task = asyncio.create_task(foo())
await task
return asyncio.get_running_loop().get_task_factory()

sentry_task_factory = run_with_eager_task_factory(sentry_init, main, task_factory)

assert sentry_task_factory._is_sentry_task_factory
assert sentry_task_factory is not task_factory