diff --git a/fixtures/events/performance_problems/m-n-plus-one-db/m-n-plus-one-prisma-client-different-descriptions.json b/fixtures/events/performance_problems/m-n-plus-one-db/m-n-plus-one-prisma-client-different-descriptions.json new file mode 100644 index 00000000000000..e6d308781b213b --- /dev/null +++ b/fixtures/events/performance_problems/m-n-plus-one-db/m-n-plus-one-prisma-client-different-descriptions.json @@ -0,0 +1,242 @@ +{ + "event_id": "1997dd9e1f434f4d9ec638624dbfd8d7", + "project": 2, + "release": null, + "dist": null, + "platform": "node", + "message": "", + "datetime": "2025-08-08T16:00:00.582776+00:00", + "tags": [ + ["browser", "Firefox 137.0"], + ["browser.name", "Firefox"], + ["client_os", "Mac OS X 10.15"], + ["client_os.name", "Mac OS X"], + ["customerType", "small-plan"], + ["environment", "development"], + ["frontendSlowdown", "False"], + ["level", "info"], + ["os", "macOS 15.3.2"], + ["os.name", "macOS"], + ["runtime", "node v18.19.1"], + ["runtime.name", "node"], + ["user", "email:ywe@example.com"], + ["server_name", "MacBookPro"], + ["transaction", "GET /products"] + ], + "culprit": "prisma:client:transaction", + "environment": "production", + "level": "info", + "location": "prisma:client:transaction", + "logger": "", + "metadata": { + "location": "prisma:client:transaction", + "title": "prisma:client:transaction" + }, + "spans": [ + { + "timestamp": 1752683532.47828, + "start_timestamp": 1752683532.371497, + + "op": "default", + "span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "prisma:engine:query", + "origin": "auto.db.otel.prisma", + "data": { + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "4808ca9185d0c670" + }, + { + "timestamp": 1752683532.394234, + "start_timestamp": 1752683532.373677, + + "op": "db", + "span_id": "f3a2b1c0d9e87765", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "origin": "auto.db.otel.prisma", + "data": { + "db.system": "postgresql", + "db.query.text": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "otel.kind": "CLIENT", + "sentry.op": "db", + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "9747ee0db22ccb33" + }, + { + "timestamp": 1752683532.394473, + "start_timestamp": 1752683532.394408, + + "op": "default", + "span_id": "a4b3c2d1e0f98876", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "prisma:engine:serialize", + "origin": "auto.db.otel.prisma", + "data": { + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "ec7cb3896f17c6fd" + }, + { + "timestamp": 1752683532.415304, + "start_timestamp": 1752683532.394817, + + "op": "db", + "span_id": "b5c4d3e2f1a09987", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "origin": "auto.db.otel.prisma", + "data": { + "db.system": "postgresql", + "db.query.text": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "otel.kind": "CLIENT", + "sentry.op": "db", + "sentry.origin": "auto.db.otel.prisma" + }, + "hash": "9747ee0db22ccb33" + }, + { + "timestamp": 1752683532.415549, + "start_timestamp": 1752683532.415481, + + "op": "default", + "span_id": "c6d5e4f3a2b1a098", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "prisma:engine:serialize", + "origin": "auto.db.otel.prisma", + "data": { + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "ec7cb3896f17c6fd" + }, + { + "timestamp": 1752683532.436118, + "start_timestamp": 1752683532.415906, + + "op": "db", + "span_id": "d7e6f5a4b3c2b1a9", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "origin": "auto.db.otel.prisma", + "data": { + "db.system": "postgresql", + "db.query.text": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "otel.kind": "CLIENT", + "sentry.op": "db", + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "9747ee0db22ccb33" + }, + { + "timestamp": 1752683532.436347, + "start_timestamp": 1752683532.436282, + + "op": "default", + "span_id": "e8f7a6b5c4d3c2ba", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "prisma:engine:serialize", + "origin": "auto.db.otel.prisma", + "data": { + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "ec7cb3896f17c6fd" + }, + { + "timestamp": 1752683532.457153, + "start_timestamp": 1752683532.436713, + + "op": "db", + "span_id": "f9a8b7c6d5e4d3cb", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "origin": "auto.db.otel.prisma", + "data": { + "db.system": "postgresql", + "db.query.text": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "otel.kind": "CLIENT", + "sentry.op": "db", + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "9747ee0db22ccb33" + }, + { + "timestamp": 1752683532.457415, + "start_timestamp": 1752683532.457346, + + "op": "default", + "span_id": "a0b9c8d7e6f5e4dc", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "prisma:engine:serialize", + "origin": "auto.db.otel.prisma", + "data": { + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "ec7cb3896f17c6fd" + }, + { + "timestamp": 1752683532.477919, + "start_timestamp": 1752683532.457749, + + "op": "db", + "span_id": "b1c0d9e8f7a6f5ed", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "origin": "auto.db.otel.prisma", + "data": { + "db.system": "postgresql", + "db.query.text": "UPDATE users SET name = $1, email = $2 WHERE id = $3", + "otel.kind": "CLIENT", + "sentry.op": "db", + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "9747ee0db22ccb33" + }, + { + "timestamp": 1752683532.478153, + "start_timestamp": 1752683532.478087, + + "op": "default", + "span_id": "c2d1e0f9a8b7a6fe", + "parent_span_id": "a8b7c6d5e4f39210", + "trace_id": "a1b2c3d4e5f67890abcdef1234567890", + "status": "ok", + "description": "prisma:engine:serialize", + "origin": "auto.db.otel.prisma", + "data": { + "sentry.origin": "auto.db.otel.prisma" + }, + + "hash": "ec7cb3896f17c6fd" + } + ] +} diff --git a/src/sentry/performance_issues/detectors/experiments/mn_plus_one_db_span_detector.py b/src/sentry/performance_issues/detectors/experiments/mn_plus_one_db_span_detector.py index 25cf381d911a39..fa8b7a2070f4a4 100644 --- a/src/sentry/performance_issues/detectors/experiments/mn_plus_one_db_span_detector.py +++ b/src/sentry/performance_issues/detectors/experiments/mn_plus_one_db_span_detector.py @@ -43,6 +43,9 @@ def _equivalent(self, a: Span, b: Span) -> bool: if not first_op or not second_op or first_op != second_op: return False + if first_op == "default": + return a.get("description") == b.get("description") + if first_op.startswith("db"): return a.get("hash") == b.get("hash") @@ -119,6 +122,14 @@ def _is_valid_pattern(self, pattern: Sequence[Span]) -> bool: found_db_op = False found_different_span = False + # Patterns shouldn't start with a serialize span, since that follows an operation or query. + first_span_description = pattern[0].get("description", "") + if ( + first_span_description == "prisma:client:serialize" + or first_span_description == "prisma:engine:serialize" + ): + return False + for span in pattern: op = span.get("op") or "" description = span.get("description") or "" @@ -187,9 +198,14 @@ def next(self, span: Span) -> tuple[MNPlusOneState, PerformanceProblem | None]: # We've broken the MN pattern, so return to the Searching state. If it # is a significant problem, also return a PerformanceProblem. - times_occurred = int(len(self.spans) / len(self.pattern)) - start_index = len(self.pattern) * times_occurred - remaining_spans = self.spans[start_index:] + [span] + + # Keep more context for pattern detection by including spans that could be + # the beginning of a new pattern. Instead of just keeping the incomplete + # remainder, keep the last pattern_length spans plus the current span. + # Keep at least the last pattern_length spans (or all if we have fewer) + pattern_length = len(self.pattern) + context_start = max(0, len(self.spans) - pattern_length) + remaining_spans = self.spans[context_start:] + [span] return ( SearchingForMNPlusOne( settings=self.settings, diff --git a/tests/sentry/performance_issues/experiments/test_m_n_plus_one_db_detector.py b/tests/sentry/performance_issues/experiments/test_m_n_plus_one_db_detector.py index a6e8bd3661c40d..177251d3a361ad 100644 --- a/tests/sentry/performance_issues/experiments/test_m_n_plus_one_db_detector.py +++ b/tests/sentry/performance_issues/experiments/test_m_n_plus_one_db_detector.py @@ -10,7 +10,6 @@ PerformanceMNPlusOneDBQueriesExperimentalGroupType, PerformanceNPlusOneExperimentalGroupType, ) -from sentry.issues.issue_occurrence import IssueEvidence from sentry.models.options.project_option import ProjectOption from sentry.performance_issues.base import DetectorType from sentry.performance_issues.detectors.experiments.mn_plus_one_db_span_detector import ( @@ -121,29 +120,21 @@ def test_detects_parallel_m_n_plus_one(self) -> None: def test_detects_prisma_client_m_n_plus_one(self) -> None: event = get_event("m-n-plus-one-db/m-n-plus-one-prisma-client") - repeated_db_span_ids = [ - span["span_id"] - for span in event["spans"] - if span.get("description", "").startswith('SELECT "public"."reviews"."id"') - ] - first_db_span = next( - span for span in event["spans"] if span["span_id"] == repeated_db_span_ids[0] - ) # Hardcoded first offender span, pattern span ids, and repititions first_offender_span_index = next( index for index, span in enumerate(event["spans"]) - if span["span_id"] == "e1c13817866a2e6f" + if span["span_id"] == "aa3a15d285888d70" ) pattern_span_ids = [ - "e1c13817866a2e6f", "aa3a15d285888d70", "add16472abc0be2e", "103c3b3e339c8a0e", "d8b2e30697d9d493", "f3edcfe2e505ef57", "e81194ca91d594e2", + "855092f3cff86380", ] num_pattern_repetitions = 15 num_spans_in_pattern = len(pattern_span_ids) @@ -159,53 +150,33 @@ def test_detects_prisma_client_m_n_plus_one(self) -> None: problems = self.find_problems(event) assert len(problems) == 1 problem = problems[0] - assert problem == PerformanceProblem( - fingerprint=f"1-{self.fingerprint_type_id}-44f4f3cc14f0f8d0c5ae372e5e8c80e7ba84f413", - op="db", - desc=first_db_span["description"], - type=self.group_type, - parent_span_ids=["1bb013326ff579a4"], - cause_span_ids=repeated_db_span_ids, - offender_span_ids=offender_span_ids, - evidence_data={}, - evidence_display=[], - ) - assert len(problem.offender_span_ids) == num_offender_spans - assert problem.evidence_data == { - "cause_span_ids": repeated_db_span_ids, - "number_repeating_spans": str(num_offender_spans), - "offender_span_ids": offender_span_ids, - "op": "db", - "parent_span": "default - render route (app) /products", - "parent_span_ids": ["1bb013326ff579a4"], - "repeating_spans": [ - "default - prisma:engine:serialize", - "default - prisma:client:operation", - "default - prisma:client:serialize", - "http.client - POST https://accelerate.prisma-data.net/5.21.1/298c9a80d6e969bf5a29b56584687fa4e2ad329bc97098090a7b081d6222e653/graphql", - "default - prisma:engine", - "default - prisma:engine:connection", - 'db - SELECT "public"."reviews"."id", "public"."reviews"."productid", "public"."reviews"."rating", "public"."reviews"."customerid", "public"."reviews"."description", "public"."reviews"."created" FROM "public"."reviews" WHERE "public"."reviews"."id" = $1 OFFSET $2 /* traceparent=\'00-ee80032db36ee0e24a2f3c2f71fd5f11-aa3a15d285888d70-01\' */', - ], - "repeating_spans_compact": [ - "prisma:engine:serialize", - "prisma:client:operation", - "prisma:client:serialize", - "POST https://accelerate.prisma-data.net/5.21.1/298c9a80d6e969bf5a29b56584687fa4e2ad329bc97098090a7b081d6222e653/graphql", - "prisma:engine", - "prisma:engine:connection", - 'SELECT "public"."reviews"."id", "public"."reviews"."productid", "public"."reviews"."rating", "public"."reviews"."customerid", "public"."reviews"."description", "public"."reviews"."created" FROM "public"."reviews" WHERE "public"."reviews"."id" = $1 OFFSET $2 /* traceparent=\'00-ee80032db36ee0e24a2f3c2f71fd5f11-aa3a15d285888d70-01\' */', - ], - "transaction_name": "GET /products", - "pattern_size": num_spans_in_pattern, - "num_pattern_repetitions": num_pattern_repetitions, - } - assert problem.evidence_display[0] == IssueEvidence( - name="Offending Spans", - value=f'db - {first_db_span["description"]}', - important=True, + assert problem.type == PerformanceNPlusOneExperimentalGroupType + assert problem.fingerprint == "1-1911-44f4f3cc14f0f8d0c5ae372e5e8c80e7ba84f413" + + assert len(problem.offender_span_ids) == num_offender_spans + assert problem.evidence_data is not None + assert problem.evidence_data["number_repeating_spans"] == str(num_offender_spans) + assert problem.evidence_data["offender_span_ids"] == offender_span_ids + assert problem.evidence_data["op"] == "db" + assert problem.evidence_data["parent_span"] == "default - render route (app) /products" + assert problem.evidence_data["parent_span_ids"] == ["1bb013326ff579a4"] + assert problem.evidence_data["transaction_name"] == "GET /products" + + def test_prisma_ops_with_different_descriptions(self) -> None: + event = get_event("m-n-plus-one-db/m-n-plus-one-prisma-client-different-descriptions") + assert len(self.find_problems(event)) == 1 + problem = self.find_problems(event)[0] + assert problem.type == PerformanceNPlusOneExperimentalGroupType + assert problem.fingerprint == "1-1911-50301e409950f4b1cc0a02d9d172684b4020ae32" + assert len(problem.offender_span_ids) == 10 + assert problem.evidence_data is not None + assert problem.evidence_data["number_repeating_spans"] == str(10) + assert ( + problem.evidence_data["repeating_spans_compact"][0] + == "UPDATE users SET name = $1, email = $2 WHERE id = $3" ) + assert problem.evidence_data["repeating_spans_compact"][1] == "prisma:engine:serialize" def test_does_not_detect_truncated_m_n_plus_one(self) -> None: event = get_event("m-n-plus-one-db/m-n-plus-one-graphql-truncated")