Skip to content
Merged
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
23 changes: 23 additions & 0 deletions sentry_sdk/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from threading import Lock
from typing import TYPE_CHECKING

import sentry_sdk
from sentry_sdk.utils import logger

if TYPE_CHECKING:
Expand Down Expand Up @@ -279,6 +280,28 @@ def setup_integrations(
return integrations


def _enable_integration(integration: "Integration") -> "Optional[Integration]":
identifier = integration.identifier
client = sentry_sdk.get_client()

with _installer_lock:
if identifier in client.integrations:
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing global check allows duplicate setup_once() after re-init

Medium Severity

The _enable_integration function only checks client.integrations (specific to the current client), but doesn't check _installed_integrations (the global set tracking all successfully installed integrations). The original setup_integrations uses _processed_integrations to prevent calling setup_once() multiple times since it's a static method that patches global state. When a user re-initializes Sentry (calling init() again), the new client has an empty integrations dict, so _enable_integration will call setup_once() again even though the event loop was already patched by a previous client, causing double instrumentation.

Fix in Cursor Fix in Web

logger.debug("Integration already enabled: %s", identifier)
return None
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm explicitly not allowing overwriting the old integration with the new one so that we don't end up double patching the event loop. We could add extra logic to restore the original code and re-patch that, but it seems like that's a lot of extra work for a usecase no one will probably need.


logger.debug("Setting up integration %s", identifier)
_processed_integrations.add(identifier)
try:
type(integration).setup_once()
integration.setup_once_with_options(client.options)
except DidNotEnable as e:
logger.debug("Did not enable integration %s: %s", identifier, e)
return None
else:
_installed_integrations.add(identifier)
return integration


def _check_minimum_version(
integration: "type[Integration]",
version: "Optional[tuple[int, ...]]",
Expand Down
38 changes: 37 additions & 1 deletion sentry_sdk/integrations/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import sentry_sdk
from sentry_sdk.consts import OP
from sentry_sdk.integrations import Integration, DidNotEnable
from sentry_sdk.integrations import Integration, DidNotEnable, _enable_integration
from sentry_sdk.utils import event_from_exception, logger, reraise

try:
Expand Down Expand Up @@ -138,3 +138,39 @@ 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 is already enabled (e.g. because it was provided in
sentry_sdk.init(integrations=[...])), 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

integration = _enable_integration(AsyncioIntegration(*args, **kwargs))
if integration is None:
return

client.integrations[integration.identifier] = integration
Comment thread
alexander-alderman-webb marked this conversation as resolved.
Outdated
107 changes: 106 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 @@ -386,3 +390,104 @@ 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):
integration = AsyncioIntegration()
sentry_init(integrations=[integration], traces_sample_rate=1.0)

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

enable_asyncio_integration()

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

# The new asyncio integration should not override the old one
assert sentry_sdk.get_client().integrations["asyncio"] == integration


@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