Performance Monitoring in GraphQL
Enrique Fueyo Ramírez is the Co-founder and CTO of Lang.ai. Here's a post on how him and his team at Lang.ai instrumented performance monitoring for GraphQL resolvers.
Tech Stack
At Lang.ai we use Sentry in our systems for Error Monitoring and recently added Performance Monitoring
Lang.ai’s User Interface is a React App using a GraphQL API.
We use Apollo to connect the frontend and the GraphQL backend.
Problem
Some months ago we started experiencing some degradation in a few graphql queries. The queries were complex (big) queries and it was tricky to debug which part of the request was slowing down the response.
Goal
We wanted to monitor the performance of each Graphql Resolver.
Ideally, we wanted to monitor the performance of every resolver without explicitly adding it (we didn’t want to proactively add a start and stop lines of code all around our functions' bodies).
Solution
Create a Sentry transaction for each graphql request.
Each Sentry transaction is initialized with its own context. Create a Transaction
import * as Sentry from '@sentry/node';
import { Transaction } from '@sentry/tracing';
import { Transaction } from "@sentry/types";
export interface Context {
// ... other context fields for your context
transaction: Transaction
}
export async function createContext(): Promise<Context> { {
// ... create other context fields
const transaction = Sentry.startTransaction({
op: "gql",
name: "GraphQLTransaction", // this will be the default name, unless the gql query has a name
})
return { transaction };
}
Add a span for each resolver
To intercept the life-cycle of each resolver and create and finish a span, we needed to create an Apollo Plugin.
import { ApolloServerPlugin } from "apollo-server-plugin-base"
import { Context } from "./context"
const plugin: ApolloServerPlugin<Context> = {
requestDidStart({ request, context }) {
if (!!request.operationName) { // set the transaction Name if we have named queries
context.transaction.setName(request.operationName!)
}
return {
willSendResponse({ context }) { // hook for transaction finished
context.transaction.finish()
},
executionDidStart() {
return {
willResolveField({ context, info }) { // hook for each new resolver
const span = context.transaction.startChild({
op: "resolver",
description: `${info.parentType.name}.${info.fieldName}`,
})
return () => { // this will execute once the resolver is finished
span.finish()
}
},
}
},
}
},
}
export default plugin
And then we have to connect all the pieces on server initialization
import { ApolloServer } from "apollo-server-micro"
import { createContext } from "./context"
import SentryPlugin from "./sentry-plugin"
const apolloServer = new ApolloServer({
// ... your ApolloServer options
// Create context function
context: ({ req, connection }) => createContext({ req, connection }),
// Add our sentry plugin
plugins: [SentryPlugin],
})
Once your server starts receiving requests it will send every transaction info to your configured Sentry account. You should see something like this:
And you can also see the detail of each individual transaction with its resolvers:
One last consideration
We could have created the transaction directly in the plugin, inside the requestDidStart
function and omit any references to the Context
. But, if we make the transaction
accessible from the Context
, each resolver can access it and we can create more spans
inside the resolvers for more fine grained information.
Accessing the transaction from the resolver should also be helpful for Sentry's Distributed Tracing.