When we hooked up our GraphQL FastAPI app with Sentry, it seemed to work out-of-the-box using Sentry’s SentryAsgiMiddleware. On second look, the most relevant part of the stack trace was “Removed because of size limits”. The Sentry events contained no useful information.
Problem
FastAPI is built on Starlette, and Starlette uses Graphene to support GraphQL. To respond with nice error messages, Graphene (by design) catches all exceptions and logs errors instead. The exceptions aren’t bubbling up to Sentry. This means our Sentry event is based on the log entry text, instead of being captured from the exception object. Without the exception object, Sentry will try to put as much of the log entry text in the message as possible, which turns out to have a limit of 512 characters.
Solution using middleware, in three steps
Exceptions should be captured using Sentry’s capture_exception method. Sentry’s middleware does that for us under the hood, but we can write a bit of middleware that does the same for us in Graphene.
Step 1: Write some Graphene middleware
import sentry_sdk
class GraphQLSentryMiddleware(object):
def resolve(self, next, root, info, **args):
promise = next(root, info, **args)
# Capture exceptions, and reraise such that Graphene can respond with a nice error message.
return promise.then(did_reject=capture_and_reraise)
async def capture_and_reraise(e):
sentry_sdk.capture_exception(e)
raise e
After being hooked up in the next steps, Graphene will invoke the resolve
method on every GraphQL request, and do the following:
- Continue the evaluation, by calling
next(root, info, **args)
and returning the promise. - When the promise is rejected, call
sentry_sdk.capture_exception
to capture the exception to Sentry. Then let it bubble up such that Graphene can respond with a nice error message.
Step 2: Add middleware to Graphene (warning: copy-pasted code ahead)
Now that we have defined our middleware, we need to add it to Graphene, such that the resolve method above is called on every GraphQL request. At the time of writing this, Starlette doesn’t provide a way to pass middleware to Graphene, so we have to extend its GraphQLApp
class. Most of the code below is copied from the execute method in starlette/graphql.py. There are two modifications:
- The
__init__
method takes a middleware argument. - The middleware argument is passed to Graphene’s
execute
method.
from starlette.graphql import GraphQLApp
from starlette.concurrency import run_in_threadpool
class GraphQLAppWithMiddleware(GraphQLApp):
def __init__(self, *args, **kwargs):
"""
:param middleware: List of Graphene middleware.
See https://docs.graphene-python.org/en/latest/execution/middleware/
"""
self._middleware = kwargs.pop('middleware', None)
super().__init__(*args, **kwargs)
async def execute( # type: ignore
self, query, variables=None, context=None, operation_name=None
):
if self.is_async:
return await self.schema.execute(
query,
variables=variables,
operation_name=operation_name,
executor=self.executor,
return_promise=True,
context=context,
middleware=self._middleware, # <-- This line was added to Starlette's execute method.
)
else:
return await run_in_threadpool(
self.schema.execute,
query,
variables=variables,
operation_name=operation_name,
context=context,
middleware=self._middleware, # <-- And this line as well.
)
Step 3:
The last step is to use GraphQLAppWithMiddleware instead of Starlette’s GraphQLApp:
from graphql.execution.executors.asyncio import AsyncioExecutor
app.add_route("/", GraphQLAppWithMiddleware(
schema=schema,
executor_class=AsyncioExecutor,
middleware=[GraphQLSentryMiddleware()]))
Final result
Detailed Sentry events! 🎉
Tagged with: #Sentry, #Graphene, #GraphQL, #FastAPI, #Starlette, #Middleware