Skip to content

Commit a1b1e7e

Browse files
committed
feat(graphene): Add span streaming support
Add support for OpenTelemetry span streaming in the Graphene GraphQL integration. When span streaming is enabled via the trace_lifecycle experiment, use sentry_sdk.traces.start_span() with attributes instead of the traditional start_span() + set_data() approach. Also improve operation_name handling to provide a sensible default when the operation name is not specified in the GraphQL request. Fixes PY-2328 Fixes #6026
1 parent 7f724ec commit a1b1e7e

2 files changed

Lines changed: 136 additions & 8 deletions

File tree

sentry_sdk/integrations/graphene.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from sentry_sdk.consts import OP
55
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
66
from sentry_sdk.scope import should_send_default_pii
7+
from sentry_sdk.tracing_utils import has_span_streaming_enabled
78
from sentry_sdk.utils import (
89
capture_internal_exceptions,
910
ensure_integration_enabled,
@@ -122,7 +123,7 @@ def _event_processor(event: "Event", hint: "Dict[str, Any]") -> "Event":
122123
def graphql_span(
123124
schema: "GraphQLSchema", source: "Union[str, Source]", kwargs: "Dict[str, Any]"
124125
) -> "Generator[None, None, None]":
125-
operation_name = kwargs.get("operation_name")
126+
operation_name = kwargs.get("operation_name") or "<unknown graphql operation>"
126127

127128
operation_type = "query"
128129
op = OP.GRAPHQL_QUERY
@@ -143,16 +144,38 @@ def graphql_span(
143144
},
144145
)
145146

146-
_graphql_span = sentry_sdk.start_span(op=op, name=operation_name)
147+
is_span_streaming_enabled = has_span_streaming_enabled(
148+
sentry_sdk.get_client().options
149+
)
147150

148-
if should_send_default_pii():
149-
_graphql_span.set_data("graphql.document", source)
150-
_graphql_span.set_data("graphql.operation.name", operation_name)
151-
_graphql_span.set_data("graphql.operation.type", operation_type)
151+
if is_span_streaming_enabled:
152+
additional_attributes = {}
153+
if should_send_default_pii():
154+
additional_attributes["graphql.document"] = source
155+
156+
_graphql_span = sentry_sdk.traces.start_span(
157+
name=operation_name,
158+
attributes={
159+
"sentry.op": op,
160+
"graphql.operation.name": operation_name,
161+
"graphql.operation.type": operation_type,
162+
**additional_attributes,
163+
},
164+
)
165+
else:
166+
_graphql_span = sentry_sdk.start_span(op=op, name=operation_name)
167+
168+
if should_send_default_pii():
169+
_graphql_span.set_data("graphql.document", source)
170+
_graphql_span.set_data("graphql.operation.name", operation_name)
171+
_graphql_span.set_data("graphql.operation.type", operation_type)
152172

153-
_graphql_span.__enter__()
173+
_graphql_span.__enter__()
154174

155175
try:
156176
yield
157177
finally:
158-
_graphql_span.__exit__(None, None, None)
178+
if is_span_streaming_enabled:
179+
_graphql_span.end() # type: ignore
180+
else:
181+
_graphql_span.__exit__(None, None, None)

tests/integrations/graphene/test_graphene.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from flask import Flask, jsonify, request
55
from graphene import ObjectType, Schema, String
66

7+
import sentry_sdk
78
from sentry_sdk.consts import OP
89
from sentry_sdk.integrations.fastapi import FastApiIntegration
910
from sentry_sdk.integrations.flask import FlaskIntegration
@@ -254,6 +255,63 @@ def graphql_server_sync():
254255
assert "graphql.document" not in span["data"]
255256

256257

258+
@pytest.mark.parametrize(
259+
"send_default_pii",
260+
[True, False],
261+
)
262+
def test_graphql_streamed_span_holds_query_information(
263+
sentry_init, capture_items, send_default_pii
264+
):
265+
sentry_init(
266+
integrations=[GrapheneIntegration(), FlaskIntegration()],
267+
traces_sample_rate=1.0,
268+
default_integrations=False,
269+
send_default_pii=send_default_pii,
270+
_experiments={"trace_lifecycle": "stream"},
271+
)
272+
items = capture_items("span")
273+
274+
schema = Schema(query=Query)
275+
276+
sync_app = Flask(__name__)
277+
278+
@sync_app.route("/graphql", methods=["POST"])
279+
def graphql_server_sync():
280+
data = request.get_json()
281+
result = schema.execute(data["query"], operation_name=data.get("operationName"))
282+
return jsonify(result.data), 200
283+
284+
query = {
285+
"query": "query GreetingQuery { hello }",
286+
"operationName": "GreetingQuery",
287+
}
288+
client = sync_app.test_client()
289+
client.post("/graphql", json=query)
290+
291+
sentry_sdk.get_client().flush()
292+
293+
spans = [item.payload for item in items]
294+
assert len(spans) == 2
295+
296+
graphql_span, flask_segment = spans
297+
298+
assert graphql_span["name"] == query["operationName"]
299+
assert graphql_span["attributes"]["sentry.op"] == OP.GRAPHQL_QUERY
300+
assert (
301+
graphql_span["attributes"]["graphql.operation.name"] == query["operationName"]
302+
)
303+
assert graphql_span["attributes"]["graphql.operation.type"] == "query"
304+
assert graphql_span["is_segment"] is False
305+
306+
if send_default_pii is True:
307+
assert graphql_span["attributes"]["graphql.document"] == query["query"]
308+
else:
309+
assert "graphql.document" not in graphql_span["attributes"]
310+
311+
assert flask_segment["is_segment"] is True
312+
assert graphql_span["parent_span_id"] == flask_segment["span_id"]
313+
314+
257315
def test_breadcrumbs_hold_query_information_on_error(sentry_init, capture_events):
258316
sentry_init(
259317
integrations=[
@@ -293,3 +351,50 @@ def graphql_server_sync():
293351
assert breadcrumb["data"]["operation_name"] == query["operationName"]
294352
assert breadcrumb["data"]["operation_type"] == "query"
295353
assert breadcrumb["type"] == "default"
354+
355+
356+
def test_breadcrumbs_hold_query_information_on_error_with_span_streaming(
357+
sentry_init, capture_items
358+
):
359+
sentry_init(
360+
integrations=[
361+
GrapheneIntegration(),
362+
],
363+
default_integrations=False,
364+
_experiments={"trace_lifecycle": "stream"},
365+
)
366+
items = capture_items("span", "event")
367+
368+
schema = Schema(query=Query)
369+
370+
sync_app = Flask(__name__)
371+
372+
@sync_app.route("/graphql", methods=["POST"])
373+
def graphql_server_sync():
374+
data = request.get_json()
375+
result = schema.execute(data["query"], operation_name=data.get("operationName"))
376+
return jsonify(result.data), 200
377+
378+
query = {
379+
"query": "query ErrorQuery { goodbye }",
380+
"operationName": "ErrorQuery",
381+
}
382+
client = sync_app.test_client()
383+
client.post("/graphql", json=query)
384+
385+
sentry_sdk.get_client().flush()
386+
387+
events = [item.payload for item in items if item.type == "event"]
388+
assert len(events) == 1
389+
390+
(event,) = events
391+
assert len(event["breadcrumbs"]) == 1
392+
393+
breadcrumbs = event["breadcrumbs"]["values"]
394+
assert len(breadcrumbs) == 1
395+
396+
(breadcrumb,) = breadcrumbs
397+
assert breadcrumb["category"] == "graphql.operation"
398+
assert breadcrumb["data"]["operation_name"] == query["operationName"]
399+
assert breadcrumb["data"]["operation_type"] == "query"
400+
assert breadcrumb["type"] == "default"

0 commit comments

Comments
 (0)