Setting up Sentry in RedwoodJS

Image for post
Image for post
Sentry issues from a RedwoodJS project

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:

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.

Image for post
Image for post
Code diff for adding @sentry/netlify-build-plugin

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

Image for post
Image for post
Environment variables for Sentry in Netlify

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:

  1. We don’t add the user to Sentry on the frontend
  2. Figuring out how to catch exceptions in Apollo Server
  3. It would be nice if this was all packaged up in a plugin for RedwoodJS and we could just install it from NPM.

Written by

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store