From 9562fff82ff40a784f588edd2d0c8054169b272b Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Jul 2026 11:00:29 -0400 Subject: [PATCH 1/3] 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=[ From a6055cd888527a3edcfa2d3c34e242b0c613c2f2 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Jul 2026 14:28:36 -0400 Subject: [PATCH 2/3] feat(strawberry): Gate GraphQL data collection behind data_collection option Apply the data_collection option to the Strawberry integration so that graphql.document, request data, and variables are only collected when explicitly enabled. send_default_pii is used as a fallback only when data_collection is not configured. GraphQL operation names are now always captured when available, independent of document or PII settings. --- sentry_sdk/integrations/strawberry.py | 44 ++- .../strawberry/test_strawberry.py | 343 ++++++++++++++++++ 2 files changed, 384 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/strawberry.py b/sentry_sdk/integrations/strawberry.py index 97c0289267..d3cb77cc4f 100644 --- a/sentry_sdk/integrations/strawberry.py +++ b/sentry_sdk/integrations/strawberry.py @@ -15,6 +15,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, package_version, ) @@ -193,8 +194,13 @@ def on_operation(self) -> "Generator[None, None, None]": return additional_attributes: "dict[str, Any]" = {} + if has_data_collection_enabled(client.options): + if client.options["data_collection"]["graphql"]["document"]: + additional_attributes["graphql.document"] = ( + self.execution_context.query + ) - if should_send_default_pii(): + elif should_send_default_pii(): additional_attributes["graphql.document"] = self.execution_context.query if operation_name: @@ -218,7 +224,12 @@ def on_operation(self) -> "Generator[None, None, None]": graphql_span.__enter__() if type(graphql_span) is Span: - 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", self.execution_context.query + ) + elif should_send_default_pii(): graphql_span.set_data("graphql.document", self.execution_context.query) graphql_span.set_data("graphql.operation.type", operation_type) @@ -465,8 +476,35 @@ def _make_request_event_processor( execution_context: "ExecutionContext", ) -> "EventProcessor": def inner(event: "Event", hint: "dict[str, Any]") -> "Event": + client_options = sentry_sdk.get_client().options with capture_internal_exceptions(): - if should_send_default_pii(): + if has_data_collection_enabled(client_options): + request_data = event.setdefault("request", {}) + if client_options["data_collection"]["graphql"]["document"]: + request_data["api_target"] = "graphql" + + if not request_data.get("data"): + execution_context_data: "dict[str, Any]" = ( + {"query": execution_context.query} + if client_options["data_collection"]["graphql"]["document"] + else {} + ) + + if ( + client_options["data_collection"]["graphql"]["variables"] + and execution_context.variables + ): + execution_context_data["variables"] = ( + execution_context.variables + ) + + if execution_context.operation_name: + execution_context_data["operationName"] = ( + execution_context.operation_name + ) + + request_data["data"] = execution_context_data + elif should_send_default_pii(): request_data = event.setdefault("request", {}) request_data["api_target"] = "graphql" diff --git a/tests/integrations/strawberry/test_strawberry.py b/tests/integrations/strawberry/test_strawberry.py index 35c19cde22..0678985d33 100644 --- a/tests/integrations/strawberry/test_strawberry.py +++ b/tests/integrations/strawberry/test_strawberry.py @@ -59,6 +59,26 @@ def error(self) -> int: return 1 / 0 +@strawberry.type +class QueryWithArg: + @strawberry.field + def fail(self, value: str) -> str: + raise RuntimeError("oh no!") + + +@strawberry.type +class QueryWithCaptureException: + @strawberry.field + def echo(self, value: str) -> str: + sentry_sdk.capture_exception(RuntimeError("boom")) + return value + + @strawberry.field + def hello(self) -> str: + sentry_sdk.capture_exception(RuntimeError("boom")) + return "Hello World" + + @strawberry.type class Mutation: @strawberry.mutation @@ -247,6 +267,196 @@ def test_do_not_capture_request_if_send_pii_is_off( } +@parameterize_strawberry_test +@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_omits_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( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, + data_collection, + send_default_pii, + expect_api_target, +): + init_kwargs = { + "integrations": [StrawberryIntegration(async_execution=async_execution)] + + framework_integrations, + "_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 = strawberry.Schema(QueryWithArg) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "query ErrorQuery($value: String!) { fail(value: $value) }" + client.post( + "/graphql", + json={ + "query": query, + "operationName": "ErrorQuery", + "variables": {"value": "boom"}, + }, + ) + + assert len(events) == 1 + + (error_event,) = events + assert error_event["exception"]["values"][0]["mechanism"]["type"] == "strawberry" + + # request.data comes from the framework integration and must not be + # overwritten by the strawberry integration + assert error_event["request"]["data"] == { + "query": query, + "operationName": "ErrorQuery", + "variables": {"value": "boom"}, + } + + if expect_api_target: + assert error_event["request"]["api_target"] == "graphql" + else: + assert "api_target" not in error_event.get("request", {}) + + +@pytest.mark.parametrize( + "data_collection,expect_query,expect_variables", + [ + pytest.param( + {"graphql": {"document": True, "variables": True}}, + True, + True, + id="document_and_variables_on", + ), + pytest.param( + {"graphql": {"document": False, "variables": True}}, + False, + True, + id="document_off_variables_on", + ), + pytest.param( + {"graphql": {"document": True, "variables": False}}, + True, + False, + id="document_on_variables_off", + ), + pytest.param( + {"graphql": {"document": False, "variables": False}}, + False, + False, + id="document_and_variables_off", + ), + pytest.param( + {"user_info": False}, + True, + True, + id="omitted_graphql_config_uses_spec_defaults", + ), + ], +) +def test_request_data_collection_no_framework( + sentry_init, capture_events, data_collection, expect_query, expect_variables +): + # capturing an event during direct schema execution (without a web + # framework integration) exercises the request data collection through + # the integration's public event-processing path + sentry_init( + integrations=[StrawberryIntegration()], + _experiments={"data_collection": data_collection}, + ) + events = capture_events() + + schema = strawberry.Schema(QueryWithCaptureException) + + query = "query EchoQuery($value: String!) { echo(value: $value) }" + schema.execute_sync( + query, + variable_values={"value": "boom"}, + operation_name="EchoQuery", + ) + + assert len(events) == 1 + + (error_event,) = events + assert error_event["exception"]["values"][0]["value"] == "boom" + + request_data = error_event["request"]["data"] + + # operationName is always collected when data collection is enabled, + # regardless of the document setting + assert request_data.get("operationName") == "EchoQuery" + + if expect_query: + assert error_event["request"]["api_target"] == "graphql" + assert request_data.get("query") == query + else: + assert "api_target" not in error_event["request"] + assert "query" not in request_data + + if expect_variables: + assert request_data.get("variables") == {"value": "boom"} + else: + assert "variables" not in request_data + + +def test_request_data_collection_no_variables(sentry_init, capture_events): + sentry_init( + integrations=[StrawberryIntegration()], + _experiments={ + "data_collection": {"graphql": {"document": True, "variables": True}} + }, + ) + events = capture_events() + + schema = strawberry.Schema(QueryWithCaptureException) + + schema.execute_sync( + "query HelloQuery { hello }", + operation_name="HelloQuery", + ) + + assert len(events) == 1 + + (error_event,) = events + assert error_event["request"]["data"] == { + "query": "query HelloQuery { hello }", + "operationName": "HelloQuery", + } + + @parameterize_strawberry_test def test_breadcrumb_no_operation_name( request, @@ -836,6 +1046,109 @@ def test_transaction_mutation( ) +@parameterize_strawberry_test +@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", + ), + ], +) +@pytest.mark.parametrize("span_streaming", [True, False]) +def test_graphql_span_data_collection( + request, + sentry_init, + capture_events, + capture_items, + client_factory, + async_execution, + framework_integrations, + data_collection, + send_default_pii, + expect_document, + span_streaming, +): + init_kwargs = { + "integrations": [StrawberryIntegration(async_execution=async_execution)] + + framework_integrations, + "traces_sample_rate": 1, + "trace_lifecycle": "stream" if span_streaming else "static", + "_experiments": {"data_collection": data_collection}, + } + if send_default_pii is not None: + init_kwargs["send_default_pii"] = send_default_pii + sentry_init(**init_kwargs) + + if span_streaming: + items = capture_items("span") + else: + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + query = "query GreetingQuery { hello }" + client.post("/graphql", json={"query": query, "operationName": "GreetingQuery"}) + + if span_streaming: + sentry_sdk.flush() + spans = [i.payload for i in items] + assert len(spans) == 5 + query_span = spans[3] + + assert query_span["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY + assert query_span["attributes"]["graphql.operation.type"] == "query" + # operation.name is always set when an operation name is present + assert query_span["attributes"]["graphql.operation.name"] == "GreetingQuery" + + if expect_document: + assert query_span["attributes"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["attributes"] + else: + assert len(events) == 1 + (transaction_event,) = events + + query_spans = [ + span + for span in transaction_event["spans"] + if span["op"] == OP.GRAPHQL_QUERY + ] + assert len(query_spans) == 1, "exactly one query span expected" + query_span = query_spans[0] + assert query_span["data"]["graphql.operation.type"] == "query" + assert query_span["data"]["graphql.operation.name"] == "GreetingQuery" + + if expect_document: + assert query_span["data"]["graphql.document"] == query + else: + assert "graphql.document" not in query_span["data"] + + @parameterize_strawberry_test def test_handle_none_query_gracefully( request, @@ -863,6 +1176,36 @@ def test_handle_none_query_gracefully( assert len(events) == 0, "expected no events to be sent to Sentry" +@parameterize_strawberry_test +def test_handle_none_query_gracefully_with_data_collection( + request, + sentry_init, + capture_events, + client_factory, + async_execution, + framework_integrations, +): + sentry_init( + integrations=[ + StrawberryIntegration(async_execution=async_execution), + ] + + framework_integrations, + _experiments={ + "data_collection": {"graphql": {"document": True, "variables": True}} + }, + ) + events = capture_events() + + schema = strawberry.Schema(Query) + + client_factory = request.getfixturevalue(client_factory) + client = client_factory(schema) + + client.post("/graphql", json={}) + + assert len(events) == 0, "expected no events to be sent to Sentry" + + @parameterize_strawberry_test @pytest.mark.parametrize("span_streaming", [True, False]) def test_span_origin( From 058e04827827d24a424411004cc1f6b5214d07be Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Jul 2026 15:33:51 -0400 Subject: [PATCH 3/3] feat(gql): Gate GraphQL data collection behind data_collection option Attach the GraphQL document and variable definitions to error events based on the structured data_collection configuration (graphql.document / graphql.variables), falling back to send_default_pii when data_collection is not set. --- sentry_sdk/integrations/gql.py | 34 +++++++- tests/integrations/gql/test_gql.py | 126 +++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/gql.py b/sentry_sdk/integrations/gql.py index dcb60e9561..95dbb5c1a0 100644 --- a/sentry_sdk/integrations/gql.py +++ b/sentry_sdk/integrations/gql.py @@ -4,6 +4,7 @@ from sentry_sdk.utils import ( ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, parse_version, ) @@ -54,12 +55,18 @@ def setup_once() -> None: def _data_from_document(document: "DocumentNode") -> "EventDataType": + client_options = sentry_sdk.get_client().options try: operation_ast = get_operation_ast(document) data: "EventDataType" = {"query": print_ast(document)} if operation_ast is not None: - data["variables"] = operation_ast.variable_definitions + if has_data_collection_enabled(client_options): + if client_options["data_collection"]["graphql"]["variables"]: + data["variables"] = operation_ast.variable_definitions + elif should_send_default_pii(): + data["variables"] = operation_ast.variable_definitions + if operation_ast.name is not None: data["operationName"] = operation_ast.name.value @@ -147,7 +154,30 @@ def 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"]: + if GraphQLRequest is not None and isinstance( + document_or_request, GraphQLRequest + ): + # In v4.0.0, gql moved to using GraphQLRequest instead of + # DocumentNode in execute + # https://github.com/graphql-python/gql/pull/556 + document = document_or_request.document + else: + document = document_or_request + + request["data"] = _data_from_document(document) + contexts = event.setdefault("contexts", {}) + response = contexts.setdefault("response", {}) + response.update( + { + "data": {"errors": errors}, + "type": response, + } + ) + + elif should_send_default_pii(): if GraphQLRequest is not None and isinstance( document_or_request, GraphQLRequest ): diff --git a/tests/integrations/gql/test_gql.py b/tests/integrations/gql/test_gql.py index 5ec794da19..77787e696d 100644 --- a/tests/integrations/gql/test_gql.py +++ b/tests/integrations/gql/test_gql.py @@ -58,6 +58,30 @@ def _execute_mock_query_with_keyword_document(response_json): return client.execute(document=query) +@responses.activate +def _execute_mock_query_with_variables(response_json): + url = "http://example.com/graphql" + query_string = """ + query Example($id: ID!) { + example(id: $id) + } + """ + + # Mock the GraphQL server response + responses.add( + method=responses.POST, + url=url, + json=response_json, + status=200, + ) + + transport = RequestsHTTPTransport(url=url) + client = Client(transport=transport) + query = gql(query_string) + + return client.execute(query, variable_values={"id": "1"}) + + _execute_query_funcs = [_execute_mock_query] if GQL_VERSION < (4,): _execute_query_funcs.append(_execute_mock_query_with_keyword_document) @@ -147,3 +171,105 @@ def test_real_gql_request_with_error_with_pii( assert "data" in event["request"] assert "response" in event["contexts"] + + +@pytest.mark.parametrize("execute_query", _execute_query_funcs) +@pytest.mark.parametrize( + "init_kwargs,expect_data", + [ + pytest.param({}, False, id="no_pii_no_data_collection"), + pytest.param({"send_default_pii": True}, True, id="legacy_pii_on"), + pytest.param( + {"_experiments": {"data_collection": {}}}, + True, + id="data_collection_defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"graphql": {"document": True}}}}, + True, + id="data_collection_document_on", + ), + pytest.param( + {"_experiments": {"data_collection": {"graphql": {"document": False}}}}, + False, + id="data_collection_document_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"graphql": {"document": False}}}, + }, + False, + id="data_collection_takes_precedence_over_send_default_pii_on", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"graphql": {"document": True}}}, + }, + True, + id="data_collection_takes_precedence_over_send_default_pii_off", + ), + ], +) +def test_real_gql_request_with_error_data_collection( + sentry_init, capture_events, execute_query, init_kwargs, expect_data +): + """ + Integration test verifying that the GQLIntegration honours the + ``data_collection`` configuration when deciding whether to attach the + GraphQL document to the event. + """ + sentry_init(integrations=[GQLIntegration()], **init_kwargs) + + event = _make_erroneous_query(capture_events, execute_query) + + if expect_data: + assert "data" in event["request"] + assert "query" in event["request"]["data"] + assert "response" in event["contexts"] + else: + assert "data" not in event["request"] + assert "response" not in event["contexts"] + + +@pytest.mark.parametrize( + "init_kwargs,expect_variables", + [ + pytest.param({"send_default_pii": True}, True, id="legacy_pii_on"), + pytest.param( + {"_experiments": {"data_collection": {}}}, + True, + id="data_collection_defaults", + ), + pytest.param( + {"_experiments": {"data_collection": {"graphql": {"variables": True}}}}, + True, + id="data_collection_variables_on", + ), + pytest.param( + {"_experiments": {"data_collection": {"graphql": {"variables": False}}}}, + False, + id="data_collection_variables_off", + ), + ], +) +def test_real_gql_request_with_error_data_collection_variables( + sentry_init, capture_events, init_kwargs, expect_variables +): + """ + Integration test verifying that the GQLIntegration honours the + ``data_collection`` ``graphql.variables`` toggle for queries that + define variables. + """ + sentry_init(integrations=[GQLIntegration()], **init_kwargs) + + event = _make_erroneous_query(capture_events, _execute_mock_query_with_variables) + + assert "data" in event["request"] + assert "query" in event["request"]["data"] + + if expect_variables: + assert event["request"]["data"].get("variables") + else: + assert not event["request"]["data"].get("variables")