From 9562fff82ff40a784f588edd2d0c8054169b272b Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Jul 2026 11:00:29 -0400 Subject: [PATCH] feat(graphene): Gate GraphQL data collection behind data_collection option The graphene integration now honors the experimental structured data_collection configuration when deciding whether to attach GraphQL-specific data. When data_collection is set, graphql.document controls whether request.api_target is marked as graphql on error events and whether the graphql.document attribute is attached to query/mutation spans (both streamed and transaction-embedded). When data_collection is not configured, behavior falls back to the existing send_default_pii gating, and data_collection takes precedence when both options are set. Adds tests covering the new gating for the event processor and both span paths, including precedence over send_default_pii. --- sentry_sdk/integrations/graphene.py | 31 ++- tests/integrations/graphene/test_graphene.py | 259 +++++++++++++++++++ 2 files changed, 281 insertions(+), 9 deletions(-) diff --git a/sentry_sdk/integrations/graphene.py b/sentry_sdk/integrations/graphene.py index ce2b545241..27eacc6607 100644 --- a/sentry_sdk/integrations/graphene.py +++ b/sentry_sdk/integrations/graphene.py @@ -9,6 +9,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, package_version, ) @@ -109,12 +110,18 @@ async def _sentry_patched_graphql_async( def _event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event": - if should_send_default_pii(): + client_options = sentry_sdk.get_client().options + + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["graphql"]["document"]: + request_info = event.setdefault("request", {}) + request_info["api_target"] = "graphql" + elif should_send_default_pii(): request_info = event.setdefault("request", {}) request_info["api_target"] = "graphql" - - elif event.get("request", {}).get("data"): - del event["request"]["data"] + else: + if event.get("request", {}).get("data"): + del event["request"]["data"] return event @@ -144,9 +151,8 @@ def graphql_span( }, ) - is_span_streaming_enabled = has_span_streaming_enabled( - sentry_sdk.get_client().options - ) + client_options = sentry_sdk.get_client().options + is_span_streaming_enabled = has_span_streaming_enabled(client_options) if is_span_streaming_enabled: if sentry_sdk.traces.get_current_span() is None: @@ -154,7 +160,10 @@ def graphql_span( return additional_attributes = {} - if should_send_default_pii(): + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["graphql"]["document"]: + additional_attributes["graphql.document"] = source + elif should_send_default_pii(): additional_attributes["graphql.document"] = source _graphql_span = sentry_sdk.traces.start_span( @@ -169,8 +178,12 @@ def graphql_span( else: _graphql_span = sentry_sdk.start_span(op=op, name=operation_name) - if should_send_default_pii(): + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["graphql"]["document"]: + _graphql_span.set_data("graphql.document", source) + elif should_send_default_pii(): _graphql_span.set_data("graphql.document", source) + _graphql_span.set_data("graphql.operation.name", operation_name) _graphql_span.set_data("graphql.operation.type", operation_type) diff --git a/tests/integrations/graphene/test_graphene.py b/tests/integrations/graphene/test_graphene.py index 101111d581..9a60a957bc 100644 --- a/tests/integrations/graphene/test_graphene.py +++ b/tests/integrations/graphene/test_graphene.py @@ -149,6 +149,103 @@ def graphql_server_sync(): assert "response" not in event["contexts"] +@pytest.mark.parametrize( + "data_collection,send_default_pii,expect_api_target", + [ + pytest.param( + {"graphql": {"document": True}}, + None, + True, + id="document_on_sets_api_target", + ), + pytest.param( + {"graphql": {"document": False}}, + None, + False, + id="document_off_does_not_set_api_target", + ), + pytest.param( + {"graphql": {"document": False}}, + True, + False, + id="data_collection_takes_precedence_over_send_default_pii_on", + ), + pytest.param( + {"graphql": {"document": True}}, + False, + True, + id="data_collection_takes_precedence_over_send_default_pii_off", + ), + ], +) +def test_event_processor_data_collection_sync( + sentry_init, capture_events, data_collection, send_default_pii, expect_api_target +): + init_kwargs = { + "integrations": [GrapheneIntegration(), FlaskIntegration()], + "_experiments": {"data_collection": data_collection}, + } + if send_default_pii is not None: + init_kwargs["send_default_pii"] = send_default_pii + sentry_init(**init_kwargs) + events = capture_events() + + schema = Schema(query=Query) + + sync_app = Flask(__name__) + + @sync_app.route("/graphql", methods=["POST"]) + def graphql_server_sync(): + data = request.get_json() + result = schema.execute(data["query"]) + return jsonify(result.data), 200 + + query = {"query": "query ErrorQuery {goodbye}"} + client = sync_app.test_client() + client.post("/graphql", json=query) + + assert len(events) == 1 + + (event,) = events + assert event["exception"]["values"][0]["mechanism"]["type"] == "graphene" + if expect_api_target: + assert event["request"]["api_target"] == "graphql" + else: + assert "api_target" not in event.get("request", {}) + + +def test_event_processor_data_collection_async(sentry_init, capture_events): + sentry_init( + integrations=[ + GrapheneIntegration(), + FastApiIntegration(), + StarletteIntegration(), + ], + _experiments={"data_collection": {"graphql": {"document": True}}}, + ) + events = capture_events() + + schema = Schema(query=Query) + + async_app = FastAPI() + + @async_app.post("/graphql") + async def graphql_server_async(request: Request): + data = await request.json() + result = await schema.execute_async(data["query"]) + return result.data + + query = {"query": "query ErrorQuery {goodbye}"} + client = TestClient(async_app) + client.post("/graphql", json=query) + + assert len(events) == 1 + + (event,) = events + assert event["exception"]["values"][0]["mechanism"]["type"] == "graphene" + assert event["request"]["api_target"] == "graphql" + + def test_no_event_if_no_errors_async(sentry_init, capture_events): sentry_init( integrations=[ @@ -255,6 +352,83 @@ def graphql_server_sync(): assert "graphql.document" not in span["data"] +@pytest.mark.parametrize( + "data_collection,send_default_pii,expect_document", + [ + pytest.param( + {"graphql": {"document": True}}, + None, + True, + id="document_on_sets_graphql_document", + ), + pytest.param( + {"graphql": {"document": False}}, + None, + False, + id="document_off_omits_graphql_document", + ), + pytest.param( + {"graphql": {"document": False}}, + True, + False, + id="data_collection_takes_precedence_over_send_default_pii_on", + ), + pytest.param( + {"graphql": {"document": True}}, + False, + True, + id="data_collection_takes_precedence_over_send_default_pii_off", + ), + ], +) +def test_graphql_span_data_collection( + sentry_init, capture_events, data_collection, send_default_pii, expect_document +): + init_kwargs = { + "integrations": [GrapheneIntegration(), FlaskIntegration()], + "traces_sample_rate": 1.0, + "default_integrations": False, + "_experiments": {"data_collection": data_collection}, + } + if send_default_pii is not None: + init_kwargs["send_default_pii"] = send_default_pii + sentry_init(**init_kwargs) + events = capture_events() + + schema = Schema(query=Query) + + sync_app = Flask(__name__) + + @sync_app.route("/graphql", methods=["POST"]) + def graphql_server_sync(): + data = request.get_json() + result = schema.execute(data["query"], operation_name=data.get("operationName")) + return jsonify(result.data), 200 + + query = { + "query": "query GreetingQuery { hello }", + "operationName": "GreetingQuery", + } + client = sync_app.test_client() + client.post("/graphql", json=query) + + assert len(events) == 1 + + (event,) = events + assert len(event["spans"]) == 1 + + (span,) = event["spans"] + assert span["op"] == OP.GRAPHQL_QUERY + assert span["description"] == query["operationName"] + assert span["data"]["graphql.operation.name"] == query["operationName"] + assert span["data"]["graphql.operation.type"] == "query" + + if expect_document: + assert span["data"]["graphql.document"] == query["query"] + else: + assert "graphql.document" not in span["data"] + + @pytest.mark.parametrize( "send_default_pii", [True, False], @@ -312,6 +486,91 @@ def graphql_server_sync(): assert graphql_span["parent_span_id"] == flask_segment["span_id"] +@pytest.mark.parametrize( + "data_collection,send_default_pii,expect_document", + [ + pytest.param( + {"graphql": {"document": True}}, + None, + True, + id="document_on_sets_graphql_document", + ), + pytest.param( + {"graphql": {"document": False}}, + None, + False, + id="document_off_omits_graphql_document", + ), + pytest.param( + {"graphql": {"document": False}}, + True, + False, + id="data_collection_takes_precedence_over_send_default_pii_on", + ), + pytest.param( + {"graphql": {"document": True}}, + False, + True, + id="data_collection_takes_precedence_over_send_default_pii_off", + ), + ], +) +def test_graphql_streamed_span_data_collection( + sentry_init, capture_items, data_collection, send_default_pii, expect_document +): + init_kwargs = { + "integrations": [GrapheneIntegration(), FlaskIntegration()], + "traces_sample_rate": 1.0, + "default_integrations": False, + "trace_lifecycle": "stream", + "_experiments": {"data_collection": data_collection}, + } + if send_default_pii is not None: + init_kwargs["send_default_pii"] = send_default_pii + sentry_init(**init_kwargs) + items = capture_items("span") + + schema = Schema(query=Query) + + sync_app = Flask(__name__) + + @sync_app.route("/graphql", methods=["POST"]) + def graphql_server_sync(): + data = request.get_json() + result = schema.execute(data["query"], operation_name=data.get("operationName")) + return jsonify(result.data), 200 + + query = { + "query": "query GreetingQuery { hello }", + "operationName": "GreetingQuery", + } + client = sync_app.test_client() + client.post("/graphql", json=query) + + sentry_sdk.get_client().flush() + + spans = [item.payload for item in items] + assert len(spans) == 2 + + graphql_span, flask_segment = spans + + assert graphql_span["name"] == query["operationName"] + assert graphql_span["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + assert ( + graphql_span["attributes"]["graphql.operation.name"] == query["operationName"] + ) + assert graphql_span["attributes"]["graphql.operation.type"] == "query" + assert graphql_span["is_segment"] is False + + if expect_document: + assert graphql_span["attributes"]["graphql.document"] == query["query"] + else: + assert "graphql.document" not in graphql_span["attributes"] + + assert flask_segment["is_segment"] is True + assert graphql_span["parent_span_id"] == flask_segment["span_id"] + + def test_breadcrumbs_hold_query_information_on_error(sentry_init, capture_events): sentry_init( integrations=[