Skip to content
Open
1 change: 1 addition & 0 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def __init__(self, options: "Optional[Dict[str, Any]]" = None) -> None:
self.monitor: "Optional[Monitor]" = None
self.log_batcher: "Optional[LogBatcher]" = None
self.metrics_batcher: "Optional[MetricsBatcher]" = None
self.integrations: "dict[str, Integration]" = {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding, and no action required:

do you know if there is a reason we have both Client.integrations and the sentry_sdk.integrations._installed_integrations global?

Copy link
Contributor Author

@sentrivana sentrivana Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Afaik the general idea is:

  • _installed_integrations keeps track of what integrations have had their setup_once already run in the current process. setup_once should, as the name suggests, only be run (i.e., monkeypatch things) once, so even if you, for example, set up a client with an integration and then set up a different client with the same integration, the latter client should not run setup_once on that integration again. (Setting up multiple clients is in general a very niche scenario though.)
  • On the other hand, client.integrations tracks the integrations that a specific client is using. So for example, if you'd enabled integration A before in another client, its setup_once would have run, and it would be in _installed_integrations. If you then create another client that shouldn't have A active, A will not have its setup_once run again, but it will still be patched, and we'll use client.integrations to check whether the patch should actually be applied or if we should exit early (this pattern).

So _installed_integrations is process-wide and means "this package has been patched", and client.integrations is client-specific, saying "this patch should actually be applied", to allow for these sorts of multi-client scenarios.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I've looked into this a bit more I see that I'm not checking _installed_integrations correctly in this PR. Will fix

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So AsyncioIntegration is special in that it patches the current event loop, and not anything global in asyncio, so it actually should not be affected by _installed_integrations 🤡 36ef8cb

Copy link
Contributor

@alexander-alderman-webb alexander-alderman-webb Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the server frameworks there should be one loop per process afaict, but letting user's re-initialize would still be good if a user also initializes the SDK with AsyncioIntegration before the event loop was started.

So looks good to me (apart from Seer's comment that may need to be addressed)!

Copy link
Contributor Author

@sentrivana sentrivana Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a bool to the patched event loop to be able to tell whether it's already been patched. That should take care of always being able to patch if not patched, and avoiding double-patching. And since this completely bypasses the _installed_integrations machinery (because knowing if we've patched in the current process has no value in the case of the asyncio integration), I opted for making everything specific to the AsyncioIntegration only instead of having a more general _enable_integration function.


def __getstate__(self, *args: "Any", **kwargs: "Any") -> "Any":
return {"options": {}}
Expand Down
50 changes: 50 additions & 0 deletions sentry_sdk/integrations/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ def patch_asyncio() -> None:
loop = asyncio.get_running_loop()
orig_task_factory = loop.get_task_factory()

# Check if already patched
if getattr(orig_task_factory, "_is_sentry_task_factory", False):
return

def _sentry_task_factory(
loop: "asyncio.AbstractEventLoop",
coro: "Coroutine[Any, Any, Any]",
Expand Down Expand Up @@ -102,6 +106,7 @@ 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

except RuntimeError:
Expand Down Expand Up @@ -138,3 +143,48 @@ class AsyncioIntegration(Integration):
@staticmethod
def setup_once() -> None:
patch_asyncio()


def enable_asyncio_integration(*args: "Any", **kwargs: "Any") -> None:
"""
Enable AsyncioIntegration with the provided options.

This is useful in scenarios where Sentry needs to be initialized before
an event loop is set up, but you still want to instrument asyncio once there
is an event loop. In that case, you can sentry_sdk.init() early on without
the AsyncioIntegration and then, once the event loop has been set up,
execute:

```python
from sentry_sdk.integrations.asyncio import enable_asyncio_integration

async def async_entrypoint():
enable_asyncio_integration()
```

Any arguments provided will be passed to AsyncioIntegration() as is.

If AsyncioIntegration has already patched the current event loop, this
function won't have any effect.

If AsyncioIntegration was provided in
sentry_sdk.init(disabled_integrations=[...]), this function will ignore that
and the integration will be enabled.
"""
client = sentry_sdk.get_client()
if not client.is_active():
return

# This function purposefully bypasses the integration machinery in
# integrations/__init__.py. _installed_integrations/_processed_integrations
# is used to prevent double patching the same module, but in the case of
# the AsyncioIntegration, we don't monkeypatch the standard library directly,
# we patch the currently running event loop, and we keep the record of doing
# that on the loop itself.
logger.debug("Setting up integration asyncio")

integration = AsyncioIntegration(*args, **kwargs)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Calling enable_asyncio_integration() with any arguments will raise a TypeError because AsyncioIntegration lacks an __init__ method to accept them.
Severity: CRITICAL

🔍 Detailed Analysis

The enable_asyncio_integration() function passes *args and **kwargs to the AsyncioIntegration constructor. However, neither AsyncioIntegration nor its base class Integration define an __init__ method. They inherit object.__init__(), which accepts no arguments. Consequently, any attempt to call enable_asyncio_integration() with arguments, as the docstring suggests, will result in a TypeError. This is confirmed by a test that explicitly mocks AsyncioIntegration.__init__ to avoid this exact error, indicating the bug is known but unaddressed in the implementation.

💡 Suggested Fix

Add an __init__ method to the AsyncioIntegration class that accepts *args and **kwargs. The method body can simply be pass if the arguments are not used. This will prevent the TypeError from object.__init__ and align the function's behavior with its documentation.

🤖 Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: sentry_sdk/integrations/asyncio.py#L186

Potential issue: The `enable_asyncio_integration()` function passes `*args` and
`**kwargs` to the `AsyncioIntegration` constructor. However, neither
`AsyncioIntegration` nor its base class `Integration` define an `__init__` method. They
inherit `object.__init__()`, which accepts no arguments. Consequently, any attempt to
call `enable_asyncio_integration()` with arguments, as the docstring suggests, will
result in a `TypeError`. This is confirmed by a test that explicitly mocks
`AsyncioIntegration.__init__` to avoid this exact error, indicating the bug is known but
unaddressed in the implementation.

Did we get this right? 👍 / 👎 to inform future reviews.
Reference ID: 8487723

integration.setup_once()

if "asyncio" not in client.integrations:
client.integrations["asyncio"] = integration
119 changes: 118 additions & 1 deletion tests/integrations/asyncio/test_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@

import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations.asyncio import AsyncioIntegration, patch_asyncio
from sentry_sdk.integrations.asyncio import (
AsyncioIntegration,
patch_asyncio,
enable_asyncio_integration,
)

try:
from contextvars import Context, ContextVar
Expand Down Expand Up @@ -229,6 +233,7 @@ def test_patch_asyncio(mock_get_running_loop):
Test that the patch_asyncio function will patch the task factory.
"""
mock_loop = mock_get_running_loop.return_value
mock_loop.get_task_factory.return_value._is_sentry_task_factory = False

patch_asyncio()

Expand Down Expand Up @@ -278,6 +283,7 @@ def test_sentry_task_factory_with_factory(mock_get_running_loop):

# The original task factory will be mocked out here, let's retrieve the value for later
orig_task_factory = mock_loop.get_task_factory.return_value
orig_task_factory._is_sentry_task_factory = False

# Retieve sentry task factory (since it is an inner function within patch_asyncio)
sentry_task_factory = get_sentry_task_factory(mock_get_running_loop)
Expand Down Expand Up @@ -340,6 +346,7 @@ def test_sentry_task_factory_context_with_factory(mock_get_running_loop):

# The original task factory will be mocked out here, let's retrieve the value for later
orig_task_factory = mock_loop.get_task_factory.return_value
orig_task_factory._is_sentry_task_factory = False

# Retieve sentry task factory (since it is an inner function within patch_asyncio)
sentry_task_factory = get_sentry_task_factory(mock_get_running_loop)
Expand Down Expand Up @@ -386,3 +393,113 @@ async def test_span_origin(

assert event["contexts"]["trace"]["origin"] == "manual"
assert event["spans"][0]["origin"] == "auto.function.asyncio"


@minimum_python_38
@pytest.mark.asyncio
async def test_delayed_enable_integration(sentry_init, capture_events):
sentry_init(traces_sample_rate=1.0)

assert "asyncio" not in sentry_sdk.get_client().integrations

events = capture_events()

with sentry_sdk.start_transaction(name="test"):
await asyncio.create_task(foo())

assert len(events) == 1
(transaction,) = events
assert not transaction["spans"]

enable_asyncio_integration()

events = capture_events()

assert "asyncio" in sentry_sdk.get_client().integrations

with sentry_sdk.start_transaction(name="test"):
await asyncio.create_task(foo())

assert len(events) == 1
(transaction,) = events
assert transaction["spans"]
assert transaction["spans"][0]["origin"] == "auto.function.asyncio"


@minimum_python_38
@pytest.mark.asyncio
async def test_delayed_enable_integration_with_options(sentry_init, capture_events):
sentry_init(traces_sample_rate=1.0)

assert "asyncio" not in sentry_sdk.get_client().integrations

mock_init = MagicMock(return_value=None)
mock_setup_once = MagicMock()
with patch(
"sentry_sdk.integrations.asyncio.AsyncioIntegration.__init__", mock_init
):
with patch(
"sentry_sdk.integrations.asyncio.AsyncioIntegration.setup_once",
mock_setup_once,
):
enable_asyncio_integration("arg", kwarg="kwarg")

assert "asyncio" in sentry_sdk.get_client().integrations
mock_init.assert_called_once_with("arg", kwarg="kwarg")
mock_setup_once.assert_called_once()


@minimum_python_38
@pytest.mark.asyncio
async def test_delayed_enable_enabled_integration(sentry_init, uninstall_integration):
# Ensure asyncio integration is not already installed from previous tests
uninstall_integration("asyncio")

integration = AsyncioIntegration()
sentry_init(integrations=[integration], traces_sample_rate=1.0)

assert "asyncio" in sentry_sdk.get_client().integrations

# Get the task factory after initial setup - it should be Sentry's
loop = asyncio.get_running_loop()
task_factory_before = loop.get_task_factory()
assert getattr(task_factory_before, "_is_sentry_task_factory", False) is True

enable_asyncio_integration()

assert "asyncio" in sentry_sdk.get_client().integrations

# The task factory should be the same (loop not re-patched)
task_factory_after = loop.get_task_factory()
assert task_factory_before is task_factory_after


@minimum_python_38
@pytest.mark.asyncio
async def test_delayed_enable_integration_after_disabling(sentry_init, capture_events):
sentry_init(disabled_integrations=[AsyncioIntegration()], traces_sample_rate=1.0)

assert "asyncio" not in sentry_sdk.get_client().integrations

events = capture_events()

with sentry_sdk.start_transaction(name="test"):
await asyncio.create_task(foo())

assert len(events) == 1
(transaction,) = events
assert not transaction["spans"]

enable_asyncio_integration()

events = capture_events()

assert "asyncio" in sentry_sdk.get_client().integrations

with sentry_sdk.start_transaction(name="test"):
await asyncio.create_task(foo())

assert len(events) == 1
(transaction,) = events
assert transaction["spans"]
assert transaction["spans"][0]["origin"] == "auto.function.asyncio"
Loading