From f2fba59c273ed92efdc4c74e201ff93e87e81051 Mon Sep 17 00:00:00 2001 From: erica Date: Thu, 18 Jun 2026 15:59:10 +0000 Subject: [PATCH 1/2] fix(performance): exclude db.cursor spans from N+1 DB detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor iteration (e.g. asyncpg BaseCursor._exec on each cursor.fetch()) produces N spans with the original SQL as their description. These are sequential FETCH calls draining a pre-existing server-side cursor, not N independently issued queries — equivalent to how db.redis and db.connection are already excluded. Paired SDK change: sentry-python asyncpg integration will tag cursor fetch spans with op='db.cursor.fetch' instead of the generic 'db' op. Co-Authored-By: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- .../detectors/n_plus_one_db_span_detector.py | 1 + .../test_n_plus_one_db_span_detector.py | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py b/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py index d8c9f9b8b76d..e81006635f0f 100644 --- a/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py +++ b/src/sentry/issue_detection/detectors/n_plus_one_db_span_detector.py @@ -123,6 +123,7 @@ def _is_db_op(self, op: str) -> bool: op.startswith("db") and not op.startswith("db.redis") and not op.startswith("db.connection") + and not op.startswith("db.cursor") ) def _maybe_use_as_source(self, span: Span) -> None: diff --git a/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py b/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py index cde8ac5a5493..d30084c43551 100644 --- a/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py +++ b/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py @@ -427,6 +427,41 @@ def test_does_not_detect_n_plus_one_with_cached_queries(self) -> None: assert self.find_problems(event) == [] + def test_does_not_detect_n_plus_one_with_cursor_fetch_spans(self) -> None: + """ + Cursor iteration (e.g. asyncpg BaseCursor._exec on each cursor.fetch()) + produces N spans with the original SQL as their description. These are + sequential FETCH calls draining a pre-existing server-side cursor, not + N independently issued queries, so they should not trigger the N+1 + detector. + """ + source_span = create_span( + "db", + 100, + "SELECT * FROM table WHERE id = %s", + hash="source_hash", + ) + + repeating_spans = [ + create_span( + "db.cursor.fetch", + 100, + "SELECT * FROM table WHERE id = %s", + hash="cursor_fetch_hash", + ) + for _ in range(11) + ] + + event = create_event([source_span] + repeating_spans) + event["contexts"] = { + "trace": { + "span_id": "a" * 16, + "op": "http.server", + } + } + + assert self.find_problems(event) == [] + @pytest.mark.django_db class NPlusOneDbSettingTest(TestCase): From 394709aaf9e1dd5e4201ac20a144819724870907 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 19 Jun 2026 13:31:57 -0400 Subject: [PATCH 2/2] Update tests given changes introduced in https://github.com/getsentry/sentry-python/pull/6609 --- .../test_n_plus_one_db_span_detector.py | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py b/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py index d30084c43551..c8a7205c6f24 100644 --- a/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py +++ b/tests/sentry/issue_detection/test_n_plus_one_db_span_detector.py @@ -427,11 +427,46 @@ def test_does_not_detect_n_plus_one_with_cached_queries(self) -> None: assert self.find_problems(event) == [] + def test_does_not_detect_n_plus_one_with_cursor_iter_spans(self) -> None: + """ + Cursor iteration (e.g. asyncpg BaseCursor._exec on each cursor.__anext__()) + produces N spans with the original SQL as their description. These are + sequential calls draining a pre-existing server-side cursor, not + N independently issued queries, so they should not trigger the N+1 + detector. + """ + source_span = create_span( + "db", + 100, + "SELECT * FROM table WHERE id = %s", + hash="source_hash", + ) + + repeating_spans = [ + create_span( + "db.cursor.iter", + 100, + "SELECT * FROM table WHERE id = %s", + hash="cursor_iter_hash", + ) + for _ in range(11) + ] + + event = create_event([source_span] + repeating_spans) + event["contexts"] = { + "trace": { + "span_id": "a" * 16, + "op": "http.server", + } + } + + assert self.find_problems(event) == [] + def test_does_not_detect_n_plus_one_with_cursor_fetch_spans(self) -> None: """ - Cursor iteration (e.g. asyncpg BaseCursor._exec on each cursor.fetch()) + Cursor iteration via FETCH calls (e.g. through asyncpg Cursor.fetch) produces N spans with the original SQL as their description. These are - sequential FETCH calls draining a pre-existing server-side cursor, not + sequential calls draining a pre-existing server-side cursor, not N independently issued queries, so they should not trigger the N+1 detector. """