Skip to content

Commit

Permalink
fix(backend): capture context creation error (#2805)
Browse files Browse the repository at this point in the history
Motivation
----------
Apollo server in more recent versions has a number of hooks that get called. E.g. `requestDidStart.didEncounterErrors` hook would not be called if the context creation already failed.

I also added `startupDidFail` for convenience.

I did not (yet) add `invalidRequestWasReceived` or `didEncounterSubsequentErrors`.

See the Apollo reference: https://www.apollographql.com/docs/apollo-server/integrations/plugins-event-reference

How to test
-----------
1. `npm run test:unit -- --no-coverage src/server/sentry/index.setupExpress.spec.ts`
2. Tests pass

fix: #2644
  • Loading branch information
roschaefer authored Oct 22, 2024
1 parent 8fae51c commit 4ec78e2
Show file tree
Hide file tree
Showing 3 changed files with 108 additions and 50 deletions.
75 changes: 43 additions & 32 deletions backend/src/server/sentry/createApolloPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,41 +20,52 @@ export const createApolloPlugin: (
if (!dsn) {
return {}
}
return {
requestDidStart() {
// eslint-disable-next-line @typescript-eslint/require-await
const didEncounterErrors = async (ctx: GraphQLRequestContextDidEncounterErrors<Context>) => {
const { operation } = ctx
if (!operation) {
return
}
for (const err of ctx.errors) {
if (err.extensions?.code === 'UNAUTHENTICATED') {
return
}
if (err instanceof AuthenticationError) {
return
}
sentry.withScope((scope) => {
// Annotate whether failing operation was query/mutation/subscription
scope.setTag('kind', operation.operation)
// Log query and variables as extras
// (make sure to strip out sensitive data!)
scope.setExtra('query', ctx.request.query)
scope.setExtra('variables', ctx.request.variables)
if (err.path) {
// We can also add the path as breadcrumb
scope.addBreadcrumb({
category: 'query-path',
message: err.path.join(' > '),
level: 'debug',
})
}
sentry.captureException(err)
const captureError = ({ error }: { error: Error }) => {
sentry.captureException(error)
return Promise.resolve()
}
// eslint-disable-next-line @typescript-eslint/require-await
const didEncounterErrors = async (ctx: GraphQLRequestContextDidEncounterErrors<Context>) => {
const { operation } = ctx
if (!operation) {
return
}
for (const err of ctx.errors) {
if (err.extensions?.code === 'UNAUTHENTICATED') {
return
}
if (err instanceof AuthenticationError) {
return
}
sentry.withScope((scope) => {
// Annotate whether failing operation was query/mutation/subscription
scope.setTag('kind', operation.operation)
// Log query and variables as extras
// (make sure to strip out sensitive data!)
scope.setExtra('query', ctx.request.query)
scope.setExtra('variables', ctx.request.variables)
if (err.path) {
// We can also add the path as breadcrumb
scope.addBreadcrumb({
category: 'query-path',
message: err.path.join(' > '),
level: 'debug',
})
}
sentry.captureException(err)
})
}
}

return {
// invalidRequestWasReceived: // Do we want to capture these errors?
contextCreationDidFail: captureError,
startupDidFail: captureError,
requestDidStart() {
const graphQLRequestListener: GraphQLRequestListener<Context> = {
didEncounterErrors,
// didEncounterSubsequentErrors: // Do we want to capture these errors?
}
const graphQLRequestListener: GraphQLRequestListener<Context> = { didEncounterErrors }
return Promise.resolve(graphQLRequestListener)
},
}
Expand Down
82 changes: 64 additions & 18 deletions backend/src/server/sentry/index.setupExpress.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import express from 'express'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import express, { json } from 'express'
import sentryTestkit from 'sentry-testkit'
import supertest from 'supertest'
import { buildSchema, Resolver, Query } from 'type-graphql'
import waitForExpect from 'wait-for-expect'

import { setupSentry } from '.'
Expand All @@ -11,31 +14,74 @@ const tk = sentryTestkit()
const testkit = tk.testkit
const transport = tk.sentryTransport as () => Transport

describe('setupSentry.setupExpress', () => {
beforeEach(() => testkit.reset())

const createExpressApp = (setupExpress: ReturnType<typeof setupSentry>['setupExpress']) => {
const app = express()
app.get('/crash-me', () => {
throw new Error('Congrats, you crashed me!')
})
setupExpress(app)
return app
@Resolver()
class ExampleResolver {
@Query(() => Boolean)
successFullQuery(): boolean {
return true
}
}

describe('setupSentry', () => {
beforeEach(() => testkit.reset())

describe('any unhandled error', () => {
let setup: ReturnType<typeof setupSentry>['setupExpress']
let setup: ReturnType<typeof setupSentry>

beforeAll(() => {
const dsn =
'https://[email protected]/4508065015922688'
setup = setupSentry({ dsn, transport }).setupExpress
setup = setupSentry({ dsn, transport })
})

it('sends the error to Sentry', async () => {
const app = createExpressApp(setup)
await supertest(app).get('/crash-me').expect(500)
await waitForExpect(() => expect(testkit.reports().length).toBeGreaterThan(0))
expect(testkit.findReport(new Error('Congrats, you crashed me!'))).toBeDefined()
describe('setupExpress', () => {
const createExpressApp = () => {
const app = express()
app.get('/crash-me', () => {
throw new Error('Congrats, you crashed me!')
})
setup.setupExpress(app)
return app
}

it('ensures that any errors from request handlers are sent to Sentry', async () => {
const app = createExpressApp()
await supertest(app).get('/crash-me').expect(500)
await waitForExpect(() => expect(testkit.reports().length).toBeGreaterThan(0))
expect(testkit.findReport(new Error('Congrats, you crashed me!'))).toBeDefined()
})
})

describe('apolloPlugin', () => {
const createExpressApolloServer = async () => {
const app = express()
const schema = await buildSchema({
resolvers: [ExampleResolver],
})
const apolloServer = new ApolloServer<never>({
schema,
plugins: [setup.apolloPlugin],
})
await apolloServer.start()
const context = () => {
throw new Error('Oh no! Error during context creation!')
}
app.use(json())
app.use(expressMiddleware(apolloServer, { context }))
setup.setupExpress(app)
return app
}

it('sends errors on apollo server context creation to Sentry', async () => {
const app = await createExpressApolloServer()
await supertest(app)
.post('/graphql')
.send({ query: '{ successFullQuery }' })
.set('Content-Type', 'application/json')
.expect(500)
await waitForExpect(() => expect(testkit.reports().length).toBeGreaterThan(0))
expect(testkit.findReport(new Error('Oh no! Error during context creation!'))).toBeDefined()
})
})
})
})
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ services:
DATABASE_URL: mysql://root:@database:3306/dreammall.earth
JWKS_URI: http://authentik:9000/application/o/dreammallearth/jwks/
NODE_ENV: production
# LOG_LEVEL: SILLY

migrations:
image: ghcr.io/dreammall-earth/dreammall.earth/backend:${IMAGE_TAG:-latest}
Expand Down

0 comments on commit 4ec78e2

Please sign in to comment.