diff --git a/docs/performance/allow_n_plus_one.rst b/docs/performance/allow_n_plus_one.rst new file mode 100644 index 0000000000..da4f60775b --- /dev/null +++ b/docs/performance/allow_n_plus_one.rst @@ -0,0 +1,22 @@ +Mark expected N+1 loops +====================== + +The SDK provides a small helper to mark transactions or spans where an N+1 loop is +expected and acceptable. + +.. code-block:: python + + from sentry_sdk.performance import allow_n_plus_one + + with sentry_sdk.start_transaction(name="process_items"): + with allow_n_plus_one("expected batch processing"): + for item in items: + process(item) + +Notes +----- + +- This helper sets the tag ``sentry.n_plus_one.ignore`` (and optional + ``sentry.n_plus_one.reason``) on the current transaction and current span. +- Server-side support is required for the N+1 detector to actually ignore + transactions with this tag. The SDK only attaches the metadata. diff --git a/sentry_sdk/__init__.py b/sentry_sdk/__init__.py index 1939be0510..84ed79abdc 100644 --- a/sentry_sdk/__init__.py +++ b/sentry_sdk/__init__.py @@ -55,6 +55,10 @@ "update_current_span", ] +# Public convenience submodule for performance helpers +from sentry_sdk import performance as performance +__all__.append("performance") + # Initialize the debug support after everything is loaded from sentry_sdk.debug import init_debug_support diff --git a/sentry_sdk/performance.py b/sentry_sdk/performance.py new file mode 100644 index 0000000000..bee52e9807 --- /dev/null +++ b/sentry_sdk/performance.py @@ -0,0 +1,53 @@ +from contextlib import contextmanager + +# 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=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 diff --git a/tests/tracing/test_allow_n_plus_one.py b/tests/tracing/test_allow_n_plus_one.py new file mode 100644 index 0000000000..1198952aad --- /dev/null +++ b/tests/tracing/test_allow_n_plus_one.py @@ -0,0 +1,25 @@ +import sentry_sdk +from sentry_sdk.performance import allow_n_plus_one + + +def test_allow_n_plus_one_sets_tag(sentry_init): + # Initialize SDK with test fixture + sentry_init() + + with sentry_sdk.start_transaction(name="tx") as tx: + with allow_n_plus_one("expected"): + # no-op loop simulated + pass + + # The tag should be set on the transaction and the active span + assert tx._tags.get("sentry.n_plus_one.ignore") is True + assert tx._tags.get("sentry.n_plus_one.reason") == "expected" + + # if a span was active, it should have been tagged as well; start a span + # to verify tagging of the active span + with sentry_sdk.start_span(op="db", name="q") as span: + with allow_n_plus_one("inner"): + pass + + assert span._tags.get("sentry.n_plus_one.ignore") is True + assert span._tags.get("sentry.n_plus_one.reason") == "inner"