From 6b91b7e4701d26a8f4d850445274ba714dfc8996 Mon Sep 17 00:00:00 2001 From: Erica Pisani Date: Fri, 24 Jul 2026 15:39:34 -0400 Subject: [PATCH] feat(ariadne): Gate GraphQL data collection behind data_collection option Filter the GraphQL document and variables attached 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. The document remains subject to max_request_body_size; variables are not. When both are disabled, any request data attached by other integrations is scrubbed. Response body capture remains tied to send_default_pii. --- sentry_sdk/integrations/ariadne.py | 27 +++- tests/integrations/ariadne/test_ariadne.py | 174 +++++++++++++++++++++ 2 files changed, 199 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/ariadne.py b/sentry_sdk/integrations/ariadne.py index 1ce228d3a5..3c5ca2919c 100644 --- a/sentry_sdk/integrations/ariadne.py +++ b/sentry_sdk/integrations/ariadne.py @@ -10,6 +10,7 @@ capture_internal_exceptions, ensure_integration_enabled, event_from_exception, + has_data_collection_enabled, package_version, ) @@ -135,8 +136,30 @@ def inner(event: "Event", hint: "dict[str, Any]") -> "Event": ) except (TypeError, ValueError): return event - - if should_send_default_pii() and request_body_within_bounds( + client_options = sentry_sdk.get_client().options + + if has_data_collection_enabled(client_options): + dc_graphql = client_options["data_collection"]["graphql"] + collect_document = dc_graphql[ + "document" + ] and request_body_within_bounds(get_client(), content_length) + collect_variables = dc_graphql["variables"] + + if collect_document or collect_variables: + request_info = event.setdefault("request", {}) + request_info["api_target"] = "graphql" + request_info["data"] = { + key: value + for key, value in data.items() + if (key != "query" or collect_document) + and (key != "variables" or collect_variables) + } + elif event.get("request", {}).get("data"): + # Neither the document nor the variables may be collected, + # so scrub any request data other integrations attached. + del event["request"]["data"] + + elif should_send_default_pii() and request_body_within_bounds( get_client(), content_length ): request_info = event.setdefault("request", {}) diff --git a/tests/integrations/ariadne/test_ariadne.py b/tests/integrations/ariadne/test_ariadne.py index d9c6b2393c..5f8ea254e5 100644 --- a/tests/integrations/ariadne/test_ariadne.py +++ b/tests/integrations/ariadne/test_ariadne.py @@ -1,8 +1,10 @@ +import pytest from ariadne import ObjectType, QueryType, gql, graphql_sync, make_executable_schema from ariadne.asgi import GraphQL from fastapi import FastAPI from fastapi.testclient import TestClient from flask import Flask, jsonify, request +from flask import request as flask_request from sentry_sdk.integrations.ariadne import AriadneIntegration from sentry_sdk.integrations.fastapi import FastApiIntegration @@ -274,3 +276,175 @@ def graphql_server(): client.post("/graphql", json=query) assert len(events) == 0 + + +ERROR_QUERY_WITH_VARIABLES = { + "query": ( + "query GreetingQuery($name: String) { greeting(name: $name) {name} error }" + ), + "variables": {"name": "some name"}, +} + + +@pytest.fixture(params=["flask", "fastapi"]) +def graphql_client(request): + """Build a test client for each supported framework, hitting an endpoint + whose resolver raises so an event is always captured.""" + + def make_client(): + schema = schema_factory() + if request.param == "flask": + app = Flask(__name__) + + @app.route("/graphql", methods=["POST"]) + def graphql_server(): + success, result = graphql_sync(schema, flask_request.get_json()) + return jsonify(result), 200 + + return app.test_client() + + async_app = FastAPI() + async_app.mount("/graphql/", GraphQL(schema)) + return TestClient(async_app) + + return make_client + + +def _init_all_integrations(sentry_init, **kwargs): + sentry_init( + integrations=[ + AriadneIntegration(), + FlaskIntegration(), + FastApiIntegration(), + StarletteIntegration(), + ], + **kwargs, + ) + + +@pytest.mark.parametrize( + "init_kwargs,expect_query,expect_variables", + [ + pytest.param( + {"_experiments": {"data_collection": {}}}, + True, + True, + id="data_collection_defaults", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "graphql": {"document": True, "variables": True} + } + } + }, + True, + True, + id="document_on_variables_on", + ), + pytest.param( + {"_experiments": {"data_collection": {"graphql": {"document": False}}}}, + False, + True, + id="document_off_variables_on", + ), + pytest.param( + {"_experiments": {"data_collection": {"graphql": {"variables": False}}}}, + True, + False, + id="document_on_variables_off", + ), + pytest.param( + { + "_experiments": { + "data_collection": { + "graphql": {"document": False, "variables": False} + } + } + }, + None, + None, + id="document_off_variables_off", + ), + pytest.param( + { + "send_default_pii": True, + "_experiments": {"data_collection": {"graphql": {"document": False}}}, + }, + False, + True, + id="data_collection_takes_precedence_over_send_default_pii_on", + ), + pytest.param( + { + "send_default_pii": False, + "_experiments": {"data_collection": {"graphql": {"document": True}}}, + }, + True, + True, + id="data_collection_takes_precedence_over_send_default_pii_off", + ), + ], +) +def test_request_data_collection( + sentry_init, + capture_events, + graphql_client, + init_kwargs, + expect_query, + expect_variables, +): + """ + Verify that the ``data_collection`` ``graphql.document`` and + ``graphql.variables`` toggles independently filter the request data + attached to error events. + """ + _init_all_integrations(sentry_init, **init_kwargs) + events = capture_events() + + graphql_client().post("/graphql", json=ERROR_QUERY_WITH_VARIABLES) + + assert len(events) == 1 + (event,) = events + + if expect_query is None: + assert "data" not in event["request"] + return + + assert event["request"]["api_target"] == "graphql" + assert ("query" in event["request"]["data"]) == expect_query + assert ("variables" in event["request"]["data"]) == expect_variables + + # Response body capture is intentionally tied to send_default_pii only. + assert ("response" in event["contexts"]) == bool( + init_kwargs.get("send_default_pii") + ) + + +def test_request_data_collection_body_out_of_bounds_still_collects_variables( + sentry_init, capture_events, graphql_client +): + """ + When the request body exceeds ``max_request_body_size``, the document is + dropped but variables (which are not subject to the bounds check) are + still collected. + """ + _init_all_integrations( + sentry_init, + max_request_body_size="small", + _experiments={"data_collection": {}}, + ) + events = capture_events() + + query = dict(ERROR_QUERY_WITH_VARIABLES) + # The integration reads Content-Length from the payload's "headers" key; + # report a size over the "small" limit (10**3). + query["headers"] = {"Content-Length": str(10**4)} + graphql_client().post("/graphql", json=query) + + assert len(events) == 1 + (event,) = events + + assert "query" not in event["request"]["data"] + assert event["request"]["data"]["variables"] == {"name": "some name"}