Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
26 changes: 25 additions & 1 deletion sentry_sdk/_compat.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,44 @@
import sys
import asyncio
import inspect

from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Callable, TypeVar, cast

if TYPE_CHECKING:
from typing import Any
from typing import TypeVar
from typing import Callable

T = TypeVar("T")
_F = TypeVar("_F", bound=Callable[..., Any])


# Use a conservative cutoff (Python 3.13+) before switching to
# inspect.iscoroutinefunction. See contributor discussion and
# references to Starlette/Uvicorn behavior for rationale.
PY313 = sys.version_info[0] == 3 and sys.version_info[1] >= 13

# Backwards-compatible version flags expected across the codebase
PY37 = sys.version_info[0] == 3 and sys.version_info[1] >= 7
PY38 = sys.version_info[0] == 3 and sys.version_info[1] >= 8
PY310 = sys.version_info[0] == 3 and sys.version_info[1] >= 10
PY311 = sys.version_info[0] == 3 and sys.version_info[1] >= 11

Copy link
Contributor

Choose a reason for hiding this comment

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

Switching asyncio.iscoroutinefunction for inspect.iscoroutinefunction is tricky because of historical incompatibility. Since we support Python versions 3.6 and up we have to care about this unfortunately.

We should use the most conservative cutoff to not break any users. So while CPython deprecated asyncio.iscoroutinefunction in version 3.12, we should probably mirror Starlette who use 3.13 because of a bug in standard libraries which have not been backported. See Kludex/starlette#2983

Also, since we vendor _is_async_callable, the change below would keep the vendor up to date. We can therefore be more confident that we won't run into edge cases, since users of starlette would have run into them already.

Suggested change
PY313 = sys.version_info[0] == 3 and sys.version_info[1] >= 13
iscoroutinefunction = inspect.iscoroutinefunction if PY313 else asyncio.iscoroutinefunction


# Public shim symbol so other modules can import a stable API. For Python
# 3.13+ prefer inspect.iscoroutinefunction, otherwise fall back to
# asyncio.iscoroutinefunction to preserve historical behavior on older Pythons.
iscoroutinefunction: Callable[[Any], bool] = cast(
Callable[[Any], bool], inspect.iscoroutinefunction if PY313 else asyncio.iscoroutinefunction
)


# We intentionally do not export `markcoroutinefunction` here. The decorator
# is only used by the Django ASGI integration and historically may be applied
# to middleware instances (non-callables). Keeping the marker local to the
# integration reduces import surface and avoids potential circular imports.


def with_metaclass(meta, *bases):
# type: (Any, *Any) -> Any
class MetaClass(type):
Expand Down
17 changes: 13 additions & 4 deletions sentry_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,10 @@ def _prepare_event(

for key in "release", "environment", "server_name", "dist":
if event.get(key) is None and self.options[key] is not None:
event[key] = str(self.options[key]).strip()
# `event` is a TypedDict (Event). mypy doesn't allow assignment
# with a non-literal key, so cast to a plain dict for this
# dynamic assignment.
cast(Dict[str, Any], event)[key] = str(self.options[key]).strip()
if event.get("sdk") is None:
sdk_info = dict(SDK_INFO)
sdk_info["integrations"] = sorted(self.integrations.keys())
Expand Down Expand Up @@ -642,7 +645,9 @@ def _prepare_event(
if event.get("exception"):
DedupeIntegration.reset_last_seen()

event = new_event
# `new_event` has type Any | None at runtime; cast to Event for mypy
# now that we've checked it's not None.
event = cast("Event", new_event)

before_send_transaction = self.options["before_send_transaction"]
if (
Expand All @@ -666,13 +671,17 @@ def _prepare_event(
quantity=spans_before + 1, # +1 for the transaction itself
)
else:
spans_delta = spans_before - len(new_event.get("spans", []))
# new_event is not None here, but mypy doesn't narrow the type
# from Any | None, so cast to Event for safe attribute access.
new_event_cast = cast("Event", new_event)
spans_delta = spans_before - len(new_event_cast.get("spans", []))
if spans_delta > 0 and self.transport is not None:
self.transport.record_lost_event(
reason="before_send", data_category="span", quantity=spans_delta
)

event = new_event
# `new_event` may be Any | None; cast to Event for mypy.
event = cast("Event", new_event)

return event

Expand Down
5 changes: 3 additions & 2 deletions sentry_sdk/integrations/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from functools import partial

import sentry_sdk
from sentry_sdk._compat import iscoroutinefunction
from sentry_sdk.api import continue_trace
from sentry_sdk.consts import OP
from sentry_sdk.integrations._asgi_common import (
Expand Down Expand Up @@ -76,10 +77,10 @@ def _looks_like_asgi3(app):
if inspect.isclass(app):
return hasattr(app, "__await__")
elif inspect.isfunction(app):
return asyncio.iscoroutinefunction(app)
return iscoroutinefunction(app)
Copy link
Contributor

Choose a reason for hiding this comment

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

No action required, just documenting.

This logic is vendored from uvicorn and they recently replaced asyncio.iscoroutinefunction with inspect.iscoroutinefunction. See Kludex/uvicorn#2659

Since we will check for Python 3.13 and up and uvicorn seems to be happy using inspect on 3.9 and up, we can rely on uvicorn having battle testing this change as well.

else:
call = getattr(app, "__call__", None) # noqa
return asyncio.iscoroutinefunction(call)
return iscoroutinefunction(call)


class SentryAsgiMiddleware:
Expand Down
58 changes: 39 additions & 19 deletions sentry_sdk/integrations/django/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
`django.core.handlers.asgi`.
"""

import sys
import asyncio
import functools
import inspect

from django.core.handlers.wsgi import WSGIRequest

import sentry_sdk
from sentry_sdk._compat import iscoroutinefunction
from sentry_sdk.consts import OP

from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
Expand All @@ -35,22 +37,6 @@
_F = TypeVar("_F", bound=Callable[..., Any])


# Python 3.12 deprecates asyncio.iscoroutinefunction() as an alias for
# inspect.iscoroutinefunction(), whilst also removing the _is_coroutine marker.
# The latter is replaced with the inspect.markcoroutinefunction decorator.
# Until 3.12 is the minimum supported Python version, provide a shim.
# This was copied from https://github.com/django/asgiref/blob/main/asgiref/sync.py
if hasattr(inspect, "markcoroutinefunction"):
iscoroutinefunction = inspect.iscoroutinefunction
markcoroutinefunction = inspect.markcoroutinefunction
else:
iscoroutinefunction = asyncio.iscoroutinefunction # type: ignore[assignment]

def markcoroutinefunction(func: "_F") -> "_F":
func._is_coroutine = asyncio.coroutines._is_coroutine # type: ignore
return func


def _make_asgi_request_event_processor(request):
# type: (ASGIRequest) -> EventProcessor
def asgi_request_event_processor(event, hint):
Expand Down Expand Up @@ -215,7 +201,41 @@ def _async_check(self):
Taken from django.utils.deprecation::MiddlewareMixin._async_check
"""
if iscoroutinefunction(self.get_response):
markcoroutinefunction(self)
# The stdlib moved the coroutine marker to
# `inspect.markcoroutinefunction` and removed the private
# `_is_coroutine` marker on newer Pythons. Historically some
# code (Django middleware) has relied on attaching that
# marker to middleware instances. We implement a conservative
# local helper which uses `inspect.markcoroutinefunction` on
# sufficiently new Pythons (3.13+), otherwise falls back to a
# best-effort shim that sets the old private marker when
# available. Keep this logic local to the integration to avoid
# widening import dependencies.

# Conservative cutoff mirroring Starlette/Uvicorn: prefer
# `inspect.iscoroutinefunction` on Python 3.13+ to avoid
# historical stdlib edge-cases.
PY313 = sys.version_info[0] == 3 and sys.version_info[1] >= 13

if hasattr(inspect, "markcoroutinefunction") and PY313:
try:
inspect.markcoroutinefunction(self)
except Exception:
# Best-effort: don't fail the application if this fails.
pass
else:
# Fallback for older Pythons: try to set the historical
# private marker used by asyncio.iscoroutinefunction().
try:
marker = getattr(asyncio.coroutines, "_is_coroutine")
except Exception:
marker = None

if marker is not None:
try:
setattr(self, "_is_coroutine", marker)
except Exception:
pass

def async_route_check(self):
# type: () -> bool
Expand All @@ -237,9 +257,9 @@ async def __acall__(self, *args, **kwargs):
middleware_span = _check_middleware_span(old_method=f)

if middleware_span is None:
return await f(*args, **kwargs) # type: ignore
return await f(*args, **kwargs)

with middleware_span:
return await f(*args, **kwargs) # type: ignore
return await f(*args, **kwargs)

return SentryASGIMixin
3 changes: 2 additions & 1 deletion sentry_sdk/integrations/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from functools import wraps

import sentry_sdk
from sentry_sdk._compat import iscoroutinefunction
from sentry_sdk.integrations import DidNotEnable
from sentry_sdk.scope import should_send_default_pii
from sentry_sdk.tracing import SOURCE_FOR_STYLE, TransactionSource
Expand Down Expand Up @@ -75,7 +76,7 @@ def _sentry_get_request_handler(*args, **kwargs):
if (
dependant
and dependant.call is not None
and not asyncio.iscoroutinefunction(dependant.call)
and not iscoroutinefunction(dependant.call)
):
old_call = dependant.call

Expand Down
5 changes: 2 additions & 3 deletions sentry_sdk/integrations/quart.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from functools import wraps

import sentry_sdk
from sentry_sdk._compat import iscoroutinefunction
from sentry_sdk.integrations import DidNotEnable, Integration
from sentry_sdk.integrations._wsgi_common import _filter_headers
from sentry_sdk.integrations.asgi import SentryAsgiMiddleware
Expand Down Expand Up @@ -113,9 +114,7 @@ def _sentry_route(*args, **kwargs):
def decorator(old_func):
# type: (Any) -> Any

if inspect.isfunction(old_func) and not asyncio.iscoroutinefunction(
old_func
):
if inspect.isfunction(old_func) and not iscoroutinefunction(old_func):

@wraps(old_func)
@ensure_integration_enabled(QuartIntegration, old_func)
Expand Down
5 changes: 3 additions & 2 deletions sentry_sdk/integrations/starlette.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from json import JSONDecodeError

import sentry_sdk
from sentry_sdk._compat import iscoroutinefunction
from sentry_sdk.consts import OP
from sentry_sdk.integrations import (
DidNotEnable,
Expand Down Expand Up @@ -415,8 +416,8 @@ def _is_async_callable(obj):
while isinstance(obj, functools.partial):
obj = obj.func

return asyncio.iscoroutinefunction(obj) or (
callable(obj) and asyncio.iscoroutinefunction(obj.__call__)
return iscoroutinefunction(obj) or (
callable(obj) and iscoroutinefunction(obj.__call__)
)


Expand Down
55 changes: 55 additions & 0 deletions sentry_sdk/performance.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from contextlib import contextmanager
from typing import Generator, Optional

# Import the helper that returns the current active span/transaction without
# importing the top-level package to avoid circular imports.
from sentry_sdk.tracing_utils import get_current_span


@contextmanager
def allow_n_plus_one(reason: Optional[str] = None) -> Generator[None, None, None]:
"""Context manager to mark the current span and its root transaction as
intentionally allowed N+1.
This sets tags on the active span and its containing transaction so that
server-side N+1 detectors (if updated to honor these tags) can ignore the
transaction. This helper is best-effort and will not raise if there is no
active span/transaction.
Usage:
with allow_n_plus_one("expected loop"):
for x in queryset:
...
"""
span = get_current_span()
if span is not None:
try:
# Tag the active span
span.set_tag("sentry.n_plus_one.ignore", True)
if reason:
span.set_tag("sentry.n_plus_one.reason", reason)

# Also tag the containing transaction if available
try:
tx = span.containing_transaction
except Exception:
tx = None

if tx is not None:
try:
tx.set_tag("sentry.n_plus_one.ignore", True)
if reason:
tx.set_tag("sentry.n_plus_one.reason", reason)
except Exception:
# best-effort: do not fail if transaction tagging fails
pass
except Exception:
# best-effort: silence any unexpected errors
pass

try:
yield
finally:
# keep tags; no cleanup required
pass

Loading