Setting up Sentry in RedwoodJS

I’ve been playing with RedwoodJS lately and while it’s still pretty early days for the framework, there is definitely a lot of promise there. I took the time to set up Sentry with RedwoodJS and I figured that it might be good to write these steps down.
If you just want the code, you can go here: https://gist.github.com/rockymeza/7dec7ddb435a6851e6e27d40b1ad0c1a
API Side
I was deploying my little RedwoodJS project to Netlify, so this first thing I did was to Google “sentry netlify” and I came up with two useful links:
- https://github.com/getsentry/sentry-netlify-build-plugin
- https://httptoolkit.tech/blog/netlify-function-error-reporting-with-sentry
The Sentry Netlify build plugin deals with submitting releases and source maps to Sentry. The plugin takes care of almost everything, so all I had to do to the code was add the plugin to the netlify.toml
file.

In the Netlify site’s setting. I went to Build & Deploy > Environment and added these environment variables:

That takes care of the Release notifications. Now let’s look at capturing exception. The article from HTTP Toolkit is a really good starting point.
First, we need to install @sentry/node
yarn workspace api add @sentry/node
Then I created a new file at api/src/lib/sentry.js
and added this code:
import * as Sentry from '@sentry/node'let sentryInitialized = false
if (process.env.REDWOOD_ENV_SENTRY_DSN && !sentryInitialized) {
Sentry.init({
dsn: process.env.REDWOOD_ENV_SENTRY_DSN,
environment: process.env.CONTEXT,
release: process.env.COMMIT_REF,
})
sentryInitialized = true
}
Note that I’m using REDWOOD_ENV_SENTRY_DSN
instead of SENTRY_DSN
. This is because I did not read the RedwoodJS documentation about Environment Variables very carefully. I wanted this variable to be available on the web side (more about that later), and all environment variables that are prefixed with REDWOOD_ENV_
get exposed to web side. If I were to go back and do it again, I would probably add this to my redwood.toml
:
[web]
includeEnvironmentVariables = ['SENTRY_DSN']
I found out about process.env.CONTEXT
and process.env.COMMIT_REF
on the Netlify docs.
Next I created a wrapper for custom functions.
import { context } from '@redwoodjs/api'async function reportError(error) {
if (!sentryInitialized) return // If you do have authentication set up, we can add
// some user data to help debug issues
if (context.currentUser) {
Sentry.configureScope((scope) => {
scope.setUser({
id: context.currentUser.id,
email: context.currentUser.email,
})
})
} if (typeof error === 'string') {
Sentry.captureMessage(error)
} else {
Sentry.captureException(error)
} await Sentry.flush()
}export const wrapFunction = (handler) => async (event, lambdaContext) => {
lambdaContext.callbackWaitsForEmptyEventLoop = false
try {
return await new Promise((resolve, reject) => {
const callback = (err, result) => {
if (err) {
reject(err)
} else {
resolve(result)
}
} const resp = handler(event, lambdaContext, callback)
if (resp?.then) {
resp.then(resolve, reject)
}
})
} catch (e) {
// This catches both sync errors & promise
// rejections, because we 'await' on the handler
await reportError(e)
throw e
}
}
Then you can just write your custom functions like this:
import { db } from 'src/lib/db'
import { wrapFunction } from 'src/lib/sentry'export const handler = wrapFunction(async (event, context) => {
return {
statusCode: 200,
body: 'Hello world',
}
})
If you notice, this is much more complicated than the one in HTTP Toolkit article. This is because we need to support both promise- and callback-based handlers. Lambda (which Netlify uses under the hood) supports both and we need to support both. We do this by creating a new Promise and await
-ing it right away.
Supporting custom functions is good, but most of the API calls are going to go to the GraphQL handler. So then I tried to wrap the graphql function, but I wasn’t getting any error reporting.
import {
createGraphQLHandler,
makeMergedSchema,
makeServices,
} from '@redwoodjs/api'
import importAll from '@redwoodjs/api/importAll.macro'import { getCurrentUser } from 'src/lib/auth'
import { db } from 'src/lib/db'
import { wrapFunction, wrapServices } from 'src/lib/sentry'const schemas = importAll('api', 'graphql')
const services = importAll('api', 'services')export const handler = wrapFunction(
createGraphQLHandler({
schema: makeMergedSchema({
schemas,
services: makeServices({ services }),
}),
db,
})
)
This doesn’t work though. Under the hood, Apollo Server catches all of the exceptions and returns GraphQL formatted errors, so we need to try to reach in deeper.
Apollo Server has a hook called formatError
, but there are two problems with that. 1) reportError
is an asynchronous function and formatError
doesn’t appear to handle promises (but I could be wrong) and 2) The Apollo Server wrapper in RedwoodJS does not give us access to formatError
.
Instead I looked for a place where we could still get access to the exceptions and the only place I could find was between makeMergedSchema
and makeServices
. So then I created the following three functions:
export const wrapServiceFunction = (fn) => async (...args) => {
try {
return await fn(...args)
} catch (e) {
await reportError(e)
throw e
}
}export const wrapService = (service) => {
return Object.keys(service).reduce((acc, key) => {
return {
...acc,
[key]: wrapServiceFunction(service[key]),
}
}, {})
}export const wrapServices = (services) => {
return Object.keys(services).reduce((acc, key) => {
return {
...acc,
[key]: wrapService(services[key]),
}
}, {})
}
wrapServices
takes all of the services and wraps the functions with wrapServiceFunction
which will report any exceptions to Sentry.
Here’s how we can use wrapServices
:
export const handler = wrapFunction(
createGraphQLHandler({
schema: makeMergedSchema({
schemas,
services: wrapServices(makeServices({ services })),
}),
db,
})
)
This will now report any errors that happen inside of the service functions to Sentry!
There are some downsides to this approach though. There may be exceptions that occur after the service function returns while Apollo Server is preparing the GraphQL response. In order to capture these exceptions, there may have to be some changes inside of Redwood or maybe even Apollo Server.
Web Side
For the web side, we can look at the official Sentry docs: https://docs.sentry.io/platforms/javascript/react/
First we need to install @sentry/browser
.
yarn workspace web add @sentry/browser
In my web/src/index.js
I first added:
import * as Sentry from '@sentry/browser'if (process.env.REDWOOD_ENV_SENTRY_DSN) {
Sentry.init({
dsn: process.env.REDWOOD_ENV_SENTRY_DSN,
})
}
Then I tried to add an error boundary around the code, but I realized later that the new Sentry-related error boundary that I was implementing was conflicting with the FatalErrorBoundary
that comes from RedwoodJS. Instead of fighting with that, I decided to merge the two and I came up with my own boundary. So first I generated a component:
yarn rw generate component FatalErrorBoundary
And then fill it out with this code
import { FatalErrorBoundary as FatalErrorBoundaryBase } from '@redwoodjs/web'
import * as Sentry from '@sentry/browser'class FatalErrorBoundary extends FatalErrorBoundaryBase {
componentDidCatch(error, errorInfo) {
Sentry.withScope((scope) => {
scope.setExtras(errorInfo)
Sentry.captureException(error)
})
}
}export default FatalErrorBoundary
Now this FatalErrorBoundary
will have the same behavior as before, but in addition, it will report exceptions to Sentry!
Now API and Web sides both have Sentry support. Huzzah! 🌲
This works well enough for me, but I do think it could be improved a bit:
- We don’t add the user to Sentry on the frontend
- Figuring out how to catch exceptions in Apollo Server
- It would be nice if this was all packaged up in a plugin for RedwoodJS and we could just install it from NPM.